diff --git a/apps/consultation-portal/screens/About/components/AboutContent/AboutRTF.tsx b/apps/consultation-portal/screens/About/components/AboutContent/AboutRTF.tsx
index 761c906d3212..dbd08b18aa96 100644
--- a/apps/consultation-portal/screens/About/components/AboutContent/AboutRTF.tsx
+++ b/apps/consultation-portal/screens/About/components/AboutContent/AboutRTF.tsx
@@ -1,22 +1,22 @@
export const RichTextAbout = `
-Ábyrgð og umsjón
+Ábyrgð og umsjón
Umsagnir í samráðsgáttinni eru birtar á ábyrgð sendanda, sem skráður er fyrir efninu. Hvert ráðuneyti, sem og aðrir skipuleggjendur samráðs, ákveða tilhögun og sjá um ritstjórn á því efni sem þau birta til samráðs. Ritstjórn á almennum upplýsingum og fyrirkomulagi gáttarinnar er samstarfsverkefni dómsmálaráðuneytis og fjármála- og efnahagsráðuneytis. Hýsing og dagleg tæknileg umsjón samráðsgáttarinnar er hjá Ísland.is sem heyrir undir fjármála- og efnahagsráðuneytið.
Ábendingar um það sem betur má fara sendist á samradsgatt@stjornarradid.is
-Markmið
+Markmið
Markmið Samráðsgáttarinnar er að auka gagnsæi og möguleika almennings og hagsmunaaðila á þátttöku í stefnumótun, reglusetningu og ákvarðanatöku opinberra aðila. Hér er á einum stað hægt að finna öll mál ráðuneyta sem birt hafa verið til samráðs við almenning. Öllum er frjálst að senda inn umsögn eða ábendingu. Tekið skal fram að til viðbótar við opið samráð á netinu geta verið annars konar samráðsferlar, svo sem þátttaka helstu hagsmunaaðila í nefndarstarfi eða sérstakt boð til þeirra um umsögn.
-Efni
+Efni
Í Samráðsgáttinni er að finna áform um lagasetningu, drög að lagafrumvörpum og reglugerðum, skjöl um stefnumótun (t.d. drög að stefnum) og fleira. Hægt er að senda inn umsögn eða ábendingu og jafnframt er mögulegt að gerast áskrifandi að sjálfvirkri vöktun upplýsinga, hvort heldur er eftir málefnasviði, stofnun eða tilteknu máli. Málefnasvið miðast við skiptingu viðfangsefna og málaflokka ríkisins samkvæmt lögum um opinber fjármál. Að samráðstímabili loknu er gerð grein fyrir úrvinnslu athugasemda og niðurstöðu máls. Fyrst um sinn munu einungis ráðuneyti setja inn mál til samráðs en líklegt er að ríkisstofnanir og e.t.v. fleiri aðilar muni bætast við síðar. Aðrir væntanlegir notendur gáttarinnar eru almenningur og hagsmunaaðilar, svo sem í atvinnulífi, félagasamtökum og fræðasamfélagi. Lögð er áhersla á skýra framsetningu og auðvelda notkun.
-Ritun umsagna
+Ritun umsagna
Til að senda inn umsögn eða ábendingu þarf að skrá sig inn í Samráðsgáttina með Íslykli eða rafrænni auðkenningu, enda birtast umsagnir að meginreglu jafnóðum. Ef umsögn er veitt fyrir hönd lögaðila, t.d. samtaka, fyrirtækis eða stofnunar, þarf að vera fyrir hendi umboð, sjá nánar hér . Ef viðkomandi hefur ekki möguleika á innskráningu er hægt að taka þátt með því að senda tölvupóst á hlutaðeigandi stjórnvald. Umsögn skal vera skýr og skipulega upp sett til að auðvelda yfirferð og mat á efni hennar. Eftirfarandi atriði skal hafa að leiðarljósi:
Best er að fylgja efnisröðun skjalsins, þó þannig að fyrst komi almennt álit um málið sé þess talin þörf. Ef t.d. er um frumvarp að ræða skal einfaldlega fylgja uppsetningu þess þannig að athugasemdir og hugleiðingar um efni einstakra greina komi fram undir númeri greinar. Óþarfi er að fjalla um aðrar greinar frumvarpsins en þær sem umsagnaraðili gerir athugasemdir við.
@@ -27,7 +27,7 @@ export const RichTextAbout = `
-Birting umsagna
+Birting umsagna
Umsagnir eru að meginreglu birtar jafnóðum og þær berast en í undantekningartilvikum koma þrjár aðrar útfærslur til greina:
@@ -41,10 +41,10 @@ export const RichTextAbout = `
-Reglur
+Reglur
Samkvæmt samþykkt ríkisstjórnar um undirbúning og frágang stjórnarfrumvarpa og þingsályktunartillagna frá 24. febrúar 2023 gilda eftirfarandi reglur um öll ráðuneyti:
-
+
Áform ríkisstjórnar um lagasetningu og frummat á áhrifum hennar skulu kynnt almenningi og hagsmunaaðilum í opnu samráði og kostur gefinn á umsögnum og ábendingum. Þetta á þó ekki við ef sérstök rök mæla gegn slíkri birtingu, svo sem ef mál er sérlega brýnt.
Hæfilegur frestur skal gefinn til athugasemda, að minnsta kosti tvær til fjórar vikur.
Niðurstöður samráðs skulu birtar eins fljótt og unnt er, að jafnaði innan þriggja mánaða frá því að umsagnarfresti lauk.
@@ -60,7 +60,7 @@ Niðurstöður samráðs skulu birtar eins fljótt og unnt er, að jafnaði inna
-Eftirfylgni
+Eftirfylgni
Undir flipanum „Tölfræði“ er hægt að kynna sér yfirlit yfir nýtingu á gáttinni, allt frá opnun.
Skrifstofa löggjafarmála í dómsmálaráðuneyti birtir einnig árlega umfjöllun um framkvæmd opins samráðs á netinu af hálfu ráðuneyta.
Sjá nánar: Vönduð lagasetning
@@ -68,7 +68,7 @@ Niðurstöður samráðs skulu birtar eins fljótt og unnt er, að jafnaði inna
-
Forsaga
+Forsaga
Samráðsgáttin var opnuð 5. febrúar 2018 og undirbúin í samstarfi samgöngu- og sveitarstjórnarráðuneytis, forsætisráðuneytis og dómsmálaráðuneytis.
Sjá nánar: Vinnuhópur um samráðsferla á netinu skilar stöðumati og tillögum
diff --git a/apps/consultation-portal/screens/About/components/TableOfContents/TableOfContents.css.ts b/apps/consultation-portal/screens/About/components/TableOfContents/TableOfContents.css.ts
new file mode 100644
index 000000000000..092441726a67
--- /dev/null
+++ b/apps/consultation-portal/screens/About/components/TableOfContents/TableOfContents.css.ts
@@ -0,0 +1,6 @@
+import { style } from '@vanilla-extract/css'
+
+export const stickyPosition = style({
+ position: 'sticky',
+ top: 100,
+})
diff --git a/apps/consultation-portal/screens/About/components/TableOfContents/TableOfContents.tsx b/apps/consultation-portal/screens/About/components/TableOfContents/TableOfContents.tsx
index c93e09475d1e..f0cce9c5a5e2 100644
--- a/apps/consultation-portal/screens/About/components/TableOfContents/TableOfContents.tsx
+++ b/apps/consultation-portal/screens/About/components/TableOfContents/TableOfContents.tsx
@@ -1,13 +1,17 @@
import React from 'react'
+
import { Box, TableOfContents as Contents } from '@island.is/island-ui/core'
+
import { scrollTo } from '../../../../hooks/useScrollSpy'
import { ABOUT_HEADINGS } from '../../../../utils/consts/consts'
import localization from '../../About.json'
+import * as styles from './TableOfContents.css'
+
const TableOfContents = () => {
const loc = localization['tableOfContents']
return (
-
+
({
diff --git a/apps/consultation-portal/screens/Advices/Advices.tsx b/apps/consultation-portal/screens/Advices/Advices.tsx
index 48001d402278..0ac7abeabdf3 100644
--- a/apps/consultation-portal/screens/Advices/Advices.tsx
+++ b/apps/consultation-portal/screens/Advices/Advices.tsx
@@ -153,7 +153,7 @@ export const AdvicesScreen = () => {
-
+
{loc.intro.title}
diff --git a/apps/consultation-portal/screens/Case/Case.json b/apps/consultation-portal/screens/Case/Case.json
index dbe41ac47648..eece1baaf44c 100644
--- a/apps/consultation-portal/screens/Case/Case.json
+++ b/apps/consultation-portal/screens/Case/Case.json
@@ -151,7 +151,7 @@
"agencyText": {
"textBefore": "Ef umsögn er send fyrir hönd samtaka, fyrirtækis eða stofnunar þarf umboð þaðan,",
"href": "https://samradsgatt.island.is/library/Files/Umbo%C3%B0%20-%20lei%C3%B0beiningar%20fyrir%20samr%C3%A1%C3%B0sg%C3%A1tt%20r%C3%A1%C3%B0uneyta.pdf",
- "textAfter": "sjá nánar hér."
+ "textAfter": "sjá nánar hér"
},
"caseStatusBox": {
"Til umsagnar": {
diff --git a/apps/consultation-portal/screens/Case/components/AdviceCard/AdviceCard.tsx b/apps/consultation-portal/screens/Case/components/AdviceCard/AdviceCard.tsx
index ed185c2167df..fa3b709c03ca 100644
--- a/apps/consultation-portal/screens/Case/components/AdviceCard/AdviceCard.tsx
+++ b/apps/consultation-portal/screens/Case/components/AdviceCard/AdviceCard.tsx
@@ -99,17 +99,23 @@ export const AdviceCard = ({ advice }: Props) => {
{getShortDate(advice.created)}
{scrollHeight > REVIEW_CARD_SCROLL_HEIGHT && (
- setOpen(!open)}>
+ setOpen(!open)}
+ >
)}
-
+
{advice?.number} -{' '}
{!advice?.isPrivate && !advice?.isHidden && advice?.participantName}
diff --git a/apps/consultation-portal/screens/Case/components/AdviceForm/AdviceForm.tsx b/apps/consultation-portal/screens/Case/components/AdviceForm/AdviceForm.tsx
index 45c95a8cec89..a24308aed15f 100644
--- a/apps/consultation-portal/screens/Case/components/AdviceForm/AdviceForm.tsx
+++ b/apps/consultation-portal/screens/Case/components/AdviceForm/AdviceForm.tsx
@@ -233,7 +233,7 @@ export const AdviceForm = ({ case: _case, refetchAdvices }: Props) => {
{date}
-
+
{loc.card.title}
diff --git a/apps/consultation-portal/screens/Case/components/AdviceForm/components/ActionBox/ActionBox.tsx b/apps/consultation-portal/screens/Case/components/AdviceForm/components/ActionBox/ActionBox.tsx
index f9107f44d23c..ff5acf01e67c 100644
--- a/apps/consultation-portal/screens/Case/components/AdviceForm/components/ActionBox/ActionBox.tsx
+++ b/apps/consultation-portal/screens/Case/components/AdviceForm/components/ActionBox/ActionBox.tsx
@@ -33,7 +33,9 @@ export const ActionBox = ({ heading, text, cta }: Props) => {
columnGap={3}
>
- {heading}
+
+ {heading}
+
{text}
{
const loc = localization['agencyText']
return (
{loc.textBefore}{' '}
-
+
{
{loc.textAfter}
+ {'.'}
)
}
diff --git a/apps/consultation-portal/screens/Case/components/BlowoutList/BlowoutList.tsx b/apps/consultation-portal/screens/Case/components/BlowoutList/BlowoutList.tsx
index c46d2467fe5d..29ee4051f073 100644
--- a/apps/consultation-portal/screens/Case/components/BlowoutList/BlowoutList.tsx
+++ b/apps/consultation-portal/screens/Case/components/BlowoutList/BlowoutList.tsx
@@ -44,12 +44,15 @@ export const BlowoutList = ({
component="button"
onClick={() => setShowList(!showList)}
className={styles.blowout}
+ title="show-list"
+ aria-label="show-list"
>
{showList && (
diff --git a/apps/consultation-portal/screens/Case/components/CaseOverview/CaseOverview.tsx b/apps/consultation-portal/screens/Case/components/CaseOverview/CaseOverview.tsx
index c80c0a121bbb..6043db95f656 100644
--- a/apps/consultation-portal/screens/Case/components/CaseOverview/CaseOverview.tsx
+++ b/apps/consultation-portal/screens/Case/components/CaseOverview/CaseOverview.tsx
@@ -50,7 +50,7 @@ export const CaseOverview = ({ chosenCase }: CaseOverviewProps) => {
wrap={true}
truncate={false}
/>
-
+
{chosenCase?.name}
@@ -60,11 +60,15 @@ export const CaseOverview = ({ chosenCase }: CaseOverviewProps) => {
)}
- {loc.shortDescriptionTitle}
+
+ {loc.shortDescriptionTitle}
+
{chosenCase?.shortDescription}
- {loc.detailedDescriptionTitle}
+
+ {loc.detailedDescriptionTitle}
+
{chosenCaseSplitted && (
{chosenCaseSplitted.map((split, idx) => {
diff --git a/apps/consultation-portal/screens/Case/components/CaseTimeline/CaseTimeline.tsx b/apps/consultation-portal/screens/Case/components/CaseTimeline/CaseTimeline.tsx
index 17fbac61c0b1..4141c070c3b5 100644
--- a/apps/consultation-portal/screens/Case/components/CaseTimeline/CaseTimeline.tsx
+++ b/apps/consultation-portal/screens/Case/components/CaseTimeline/CaseTimeline.tsx
@@ -49,7 +49,7 @@ export const CaseTimeline = ({ chosenCase }: CaseTimelineProps) => {
return (
{!isMobile && (
-
+
{loc.title}
)}
diff --git a/apps/consultation-portal/screens/Case/components/DocFileName/DocFileName.tsx b/apps/consultation-portal/screens/Case/components/DocFileName/DocFileName.tsx
index 932736367a76..24da6e0571db 100644
--- a/apps/consultation-portal/screens/Case/components/DocFileName/DocFileName.tsx
+++ b/apps/consultation-portal/screens/Case/components/DocFileName/DocFileName.tsx
@@ -4,6 +4,7 @@ import env from '../../../../lib/environment'
import { isDocumentLink, renderDocFileName } from '../../utils'
import localization from '../../Case.json'
import * as styles from './DocFileName.css'
+import { useIsMobile } from '../../../../hooks'
interface Props {
doc: Document
@@ -11,6 +12,7 @@ interface Props {
}
const DocFileName = ({ doc, isAdvice = false }: Props) => {
+ const { isMobile } = useIsMobile()
const loc = localization['caseDocuments']
const isLink = isDocumentLink(doc)
const icon = isLink ? 'link' : 'document'
@@ -38,7 +40,12 @@ const DocFileName = ({ doc, isAdvice = false }: Props) => {
newTab
>
<>
-
+
{linkDesc}
{
const loc = localization['renderAdvices']
return (
-
+
{`${loc.advices.title} (${adviceCount ? adviceCount : 0})`}
{children}
diff --git a/apps/consultation-portal/screens/Case/components/Stacked/Stacked.tsx b/apps/consultation-portal/screens/Case/components/Stacked/Stacked.tsx
index 83f31a89e996..7566e76ef4e2 100644
--- a/apps/consultation-portal/screens/Case/components/Stacked/Stacked.tsx
+++ b/apps/consultation-portal/screens/Case/components/Stacked/Stacked.tsx
@@ -10,7 +10,7 @@ interface Props {
const Stacked = ({ headingColor = 'blue400', title, children }: Props) => {
return (
-
+
{title}
{children}
diff --git a/apps/consultation-portal/screens/Home/components/Filter/components/Filterbox.tsx b/apps/consultation-portal/screens/Home/components/Filter/components/Filterbox.tsx
index 3a409e94b5de..dde391cd26a6 100644
--- a/apps/consultation-portal/screens/Home/components/Filter/components/Filterbox.tsx
+++ b/apps/consultation-portal/screens/Home/components/Filter/components/Filterbox.tsx
@@ -114,7 +114,9 @@ export const FilterBox = ({
>
- {title}
+
+ {title}
+
{
{loc.statisticBox.label}
{statistic ? (
- {`${statistic} ${loc.statisticBox.text}`}
+ {`${statistic} ${loc.statisticBox.text}`}
) : (
diff --git a/apps/consultation-portal/screens/Home/components/MobileFilter/MobileFilter.tsx b/apps/consultation-portal/screens/Home/components/MobileFilter/MobileFilter.tsx
index 252e5ccb805a..ab88bb6f6e16 100644
--- a/apps/consultation-portal/screens/Home/components/MobileFilter/MobileFilter.tsx
+++ b/apps/consultation-portal/screens/Home/components/MobileFilter/MobileFilter.tsx
@@ -219,6 +219,7 @@ export const MobileFilter = ({
/>
+
{dontShowNew ? (
@@ -86,7 +86,6 @@ const SubscriptionTable = ({
key={subscribedToAllNewObj.key}
item={subscribedToAllNewObj}
idx={0}
- mdBreakpoint={mdBreakpoint}
currentTab={currentTab}
isGeneralSubscription
subscriptionArray={subscriptionArray}
@@ -100,7 +99,6 @@ const SubscriptionTable = ({
key={subscribedToAllChangesObj.key}
item={subscribedToAllChangesObj}
idx={1}
- mdBreakpoint={mdBreakpoint}
currentTab={currentTab}
isGeneralSubscription
subscriptionArray={subscriptionArray}
@@ -116,7 +114,6 @@ const SubscriptionTable = ({
key={item.key}
item={item}
idx={idx + generalSubscriptionCount}
- mdBreakpoint={mdBreakpoint}
currentTab={currentTab}
subscriptionArray={subscriptionArray}
setSubscriptionArray={setSubscriptionArray}
diff --git a/apps/consultation-portal/screens/Subscriptions/components/SubscriptionTable/components/SubscriptionTableHeader/SubscriptionTableHeader.tsx b/apps/consultation-portal/screens/Subscriptions/components/SubscriptionTable/components/SubscriptionTableHeader/SubscriptionTableHeader.tsx
index 73f4e2ad21c0..c3b22e07a23a 100644
--- a/apps/consultation-portal/screens/Subscriptions/components/SubscriptionTable/components/SubscriptionTableHeader/SubscriptionTableHeader.tsx
+++ b/apps/consultation-portal/screens/Subscriptions/components/SubscriptionTable/components/SubscriptionTableHeader/SubscriptionTableHeader.tsx
@@ -1,95 +1,47 @@
import React from 'react'
import {
Icon,
+ Inline,
Table as T,
+ TextProps,
UseBoxStylesProps,
- useBreakpoint,
} from '@island.is/island-ui/core'
-import * as styles from '../../SubscriptionTable.css'
import { Area } from '../../../../../../types/enums'
-import { useIsMobile } from '../../../../../../hooks'
+
+import * as styles from '../../SubscriptionTable.css'
interface TableHeaderProps {
currentTab: Area
}
+
const Headers = {
- Mál: ['Málsnr.', 'Heiti máls', 'Málsnúmer og heiti máls'],
- Stofnanir: ['Stofnun'],
- Málefnasvið: ['Málefnasvið'],
+ Mál: 'Málsnúmer og heiti máls',
+ Stofnanir: 'Stofnun',
+ Málefnasvið: 'Málefnasvið',
}
const SubscriptionTableHeader = ({ currentTab }: TableHeaderProps) => {
const { Head, Row, HeadData } = T
- const { md: mdBreakpoint } = useBreakpoint()
- const { isMobile } = useIsMobile()
- const mobileBox = {
+ const box = {
background: 'transparent',
borderColor: 'transparent',
className: styles.paddingRightZero,
} as UseBoxStylesProps
- const desktopBox = {
- ...mobileBox,
- width: 'touchable',
- } as UseBoxStylesProps
- const boxToUse = isMobile ? mobileBox : desktopBox
+ const text = { variant: 'h4', whiteSpace: 'nowrap' } as TextProps
return (
-
-
+
+
+
+ {Headers[currentTab]}
+
- {currentTab !== Area.case ? (
-
- {Headers[currentTab][0]}
-
- ) : mdBreakpoint ? (
- <>
-
- {Headers[currentTab][0]}
-
-
- {Headers[currentTab][1]}
-
- >
- ) : (
-
- {Headers[currentTab][2]}
-
- )}
)
diff --git a/apps/consultation-portal/screens/Subscriptions/components/SubscriptionTable/components/SubscriptionTableItem/SubscriptionTableItem.tsx b/apps/consultation-portal/screens/Subscriptions/components/SubscriptionTable/components/SubscriptionTableItem/SubscriptionTableItem.tsx
index 6831856e1f70..243b05c2ceb3 100644
--- a/apps/consultation-portal/screens/Subscriptions/components/SubscriptionTable/components/SubscriptionTableItem/SubscriptionTableItem.tsx
+++ b/apps/consultation-portal/screens/Subscriptions/components/SubscriptionTable/components/SubscriptionTableItem/SubscriptionTableItem.tsx
@@ -5,6 +5,7 @@ import {
Stack,
FocusableBox,
LinkV2,
+ Box,
} from '@island.is/island-ui/core'
import { mapIsToEn } from '../../../../../../utils/helpers'
import * as styles from '../../SubscriptionTable.css'
@@ -21,7 +22,6 @@ interface Props {
item: SubscriptionTableItemType
idx: number
currentTab: Area
- mdBreakpoint: boolean
isGeneralSubscription?: boolean
subscriptionArray: SubscriptionArray
setSubscriptionArray: (_: SubscriptionArray) => void
@@ -31,7 +31,6 @@ const SubscriptionTableItem = ({
item,
idx,
currentTab,
- mdBreakpoint,
isGeneralSubscription,
subscriptionArray,
setSubscriptionArray,
@@ -86,6 +85,7 @@ const SubscriptionTableItem = ({
return (
{
+ const Stacked = ({
+ upperText,
+ lowerText,
+ }: {
+ upperText: string
+ lowerText: string
+ }) => {
return (
-
+
- {isGeneralSubscription ? loc.allCases : `S-${item.caseNumber}`}
+ {upperText}
- {item.name}
+ {lowerText}
)
}
+ const areaIsNotCase = currentTab !== Area.case
+
+ const LabelBox = () => {
+ if (isGeneralSubscription) {
+ return
+ }
+ if (areaIsNotCase) {
+ return (
+
+ {item.name}
+
+ )
+ }
+ return (
+
+
+
+ )
+ }
+
return (
- <>
-
-
-
-
- {currentTab !== Area.case ? (
- isGeneralSubscription ? (
-
-
- {loc.allCases}
-
-
- {item.name}
-
-
- ) : (
-
-
- {item.name}
-
-
- )
- ) : mdBreakpoint ? (
- <>
-
-
- {isGeneralSubscription ? loc.allCases : `S-${item.caseNumber}`}
-
-
-
- {isGeneralSubscription ? (
-
- {item.name}
-
- ) : (
-
-
- {item.name}
-
-
- )}
-
- >
- ) : (
- <>
-
- {isGeneralSubscription ? (
-
- ) : (
-
-
-
- )}
-
- >
- )}
-
- <>>
-
-
- >
+
+
+
+
+
+ }
+ checked={item.checked}
+ onChange={checkboxHandler}
+ name={`checkbox-for-${item.key}`}
+ />
+
+
+ <>>
+
+
)
}
diff --git a/apps/consultation-portal/screens/Subscriptions/components/SubscriptionsSkeleton/SubscriptionsSkeleton.tsx b/apps/consultation-portal/screens/Subscriptions/components/SubscriptionsSkeleton/SubscriptionsSkeleton.tsx
index edeabedb13fb..d9608e87a2db 100644
--- a/apps/consultation-portal/screens/Subscriptions/components/SubscriptionsSkeleton/SubscriptionsSkeleton.tsx
+++ b/apps/consultation-portal/screens/Subscriptions/components/SubscriptionsSkeleton/SubscriptionsSkeleton.tsx
@@ -87,6 +87,7 @@ const SubscriptionsSkeleton = ({
AmountBackendModel, 'applicationId')
- @ApiProperty({ type: AmountBackendModel, nullable: true })
+ @ApiProperty({ type: () => AmountBackendModel, nullable: true })
amount?: AmountBackendModel
@Column({
@@ -224,6 +224,6 @@ export class ApplicationBackendModel extends Model {
navSuccess?: boolean
@HasMany(() => DirectTaxPaymentBackendModel, 'applicationId')
- @ApiProperty({ type: DirectTaxPaymentBackendModel, isArray: true })
+ @ApiProperty({ type: () => DirectTaxPaymentBackendModel, isArray: true })
directTaxPayments!: DirectTaxPaymentBackendModel[]
}
diff --git a/apps/judicial-system/api/src/app/modules/case-list/interceptors/caseList.interceptor.ts b/apps/judicial-system/api/src/app/modules/case-list/interceptors/caseList.interceptor.ts
index 4f915b00d8b3..15e5571a24f5 100644
--- a/apps/judicial-system/api/src/app/modules/case-list/interceptors/caseList.interceptor.ts
+++ b/apps/judicial-system/api/src/app/modules/case-list/interceptors/caseList.interceptor.ts
@@ -10,10 +10,10 @@ import {
import { CaseListEntry } from '../models/caseList.model'
-function getAppealedDate(
+const getAppealedDate = (
prosecutorPostponedAppealDate?: string,
accusedPostponedAppealDate?: string,
-): string | undefined {
+): string | undefined => {
return prosecutorPostponedAppealDate ?? accusedPostponedAppealDate
}
diff --git a/apps/judicial-system/api/src/app/modules/case/case.resolver.ts b/apps/judicial-system/api/src/app/modules/case/case.resolver.ts
index b409cbd4591e..31a8a82e38e0 100644
--- a/apps/judicial-system/api/src/app/modules/case/case.resolver.ts
+++ b/apps/judicial-system/api/src/app/modules/case/case.resolver.ts
@@ -1,13 +1,5 @@
import { Inject, UseGuards, UseInterceptors } from '@nestjs/common'
-import {
- Args,
- Context,
- Mutation,
- Parent,
- Query,
- ResolveField,
- Resolver,
-} from '@nestjs/graphql'
+import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql'
import type { Logger } from '@island.is/logging'
import { LOGGER_PROVIDER } from '@island.is/logging'
@@ -34,7 +26,6 @@ import { TransitionCaseInput } from './dto/transitionCase.input'
import { UpdateCaseInput } from './dto/updateCase.input'
import { CaseInterceptor } from './interceptors/case.interceptor'
import { Case } from './models/case.model'
-import { Notification } from './models/notification.model'
import { RequestSignatureResponse } from './models/requestSignature.response'
import { SendNotificationResponse } from './models/sendNotification.response'
import { SignatureConfirmationResponse } from './models/signatureConfirmation.response'
diff --git a/apps/judicial-system/api/src/app/modules/case/interceptors/limitedAccessCase.transformer.ts b/apps/judicial-system/api/src/app/modules/case/interceptors/limitedAccessCase.transformer.ts
index 761d8b45ea78..33fda5d42163 100644
--- a/apps/judicial-system/api/src/app/modules/case/interceptors/limitedAccessCase.transformer.ts
+++ b/apps/judicial-system/api/src/app/modules/case/interceptors/limitedAccessCase.transformer.ts
@@ -1,6 +1,8 @@
+import { getLatestDateType } from '@island.is/judicial-system/types'
import {
CaseState,
completedCaseStates,
+ DateType,
RequestSharedWithDefender,
} from '@island.is/judicial-system/types'
@@ -21,8 +23,9 @@ const RequestSharedWithDefenderAllowedStates: {
[RequestSharedWithDefender.NOT_SHARED]: completedCaseStates,
}
-export function canDefenderViewRequest(theCase: Case) {
- const { requestSharedWithDefender, state, courtDate } = theCase
+export const canDefenderViewRequest = (theCase: Case) => {
+ const { requestSharedWithDefender, state } = theCase
+ const courtDate = getLatestDateType(DateType.COURT_DATE, theCase.dateLogs)
if (!requestSharedWithDefender) {
return false
@@ -39,7 +42,7 @@ export function canDefenderViewRequest(theCase: Case) {
)
}
-export function transformLimitedAccessCase(theCase: Case): Case {
+export const transformLimitedAccessCase = (theCase: Case): Case => {
return {
...theCase,
caseResentExplanation: canDefenderViewRequest(theCase)
diff --git a/apps/judicial-system/api/src/app/modules/case/models/case.model.ts b/apps/judicial-system/api/src/app/modules/case/models/case.model.ts
index 056772907ed2..00feb859bb4e 100644
--- a/apps/judicial-system/api/src/app/modules/case/models/case.model.ts
+++ b/apps/judicial-system/api/src/app/modules/case/models/case.model.ts
@@ -27,6 +27,7 @@ import { CaseFile } from '../../file'
import { IndictmentCount } from '../../indictment-count'
import { Institution } from '../../institution'
import { User } from '../../user'
+import { DateLog } from './dateLog.model'
import { EventLog } from './eventLog.model'
import { Notification } from './notification.model'
@@ -164,9 +165,6 @@ export class Case {
@Field(() => SessionArrangements, { nullable: true })
readonly sessionArrangements?: SessionArrangements
- @Field({ nullable: true })
- readonly courtDate?: string
-
@Field({ nullable: true })
readonly courtLocation?: string
@@ -377,6 +375,9 @@ export class Case {
@Field(() => [EventLog], { nullable: true })
readonly eventLogs?: EventLog[]
+ @Field(() => [DateLog], { nullable: true })
+ readonly dateLogs?: DateLog[]
+
@Field({ nullable: true })
readonly appealValidToDate?: string
diff --git a/apps/judicial-system/api/src/app/modules/case/models/dateLog.model.ts b/apps/judicial-system/api/src/app/modules/case/models/dateLog.model.ts
new file mode 100644
index 000000000000..2dc2fc94a7e9
--- /dev/null
+++ b/apps/judicial-system/api/src/app/modules/case/models/dateLog.model.ts
@@ -0,0 +1,23 @@
+import { Field, ID, ObjectType, registerEnumType } from '@nestjs/graphql'
+
+import { DateType } from '@island.is/judicial-system/types'
+
+registerEnumType(DateType, { name: 'DateType' })
+
+@ObjectType()
+export class DateLog {
+ @Field(() => ID)
+ readonly id!: string
+
+ @Field({ nullable: true })
+ readonly created?: string
+
+ @Field({ nullable: true })
+ readonly caseId?: string
+
+ @Field(() => DateType, { nullable: true })
+ readonly dateType?: DateType
+
+ @Field({ nullable: true })
+ readonly date?: string
+}
diff --git a/apps/judicial-system/backend/migrations/20240409124300-update-notification.js b/apps/judicial-system/backend/migrations/20240409124300-update-notification.js
new file mode 100644
index 000000000000..6b8eb83570f8
--- /dev/null
+++ b/apps/judicial-system/backend/migrations/20240409124300-update-notification.js
@@ -0,0 +1,63 @@
+'use strict'
+const replaceEnum = require('sequelize-replace-enum-postgres').default
+
+module.exports = {
+ up: (queryInterface) => {
+ // replaceEnum does not support transactions
+ return replaceEnum({
+ queryInterface,
+ tableName: 'notification',
+ columnName: 'type',
+ newValues: [
+ 'HEADS_UP',
+ 'READY_FOR_COURT',
+ 'RECEIVED_BY_COURT',
+ 'COURT_DATE',
+ 'RULING',
+ 'MODIFIED',
+ 'REVOKED',
+ 'DEFENDER_ASSIGNED',
+ 'DEFENDANTS_NOT_UPDATED_AT_COURT',
+ 'APPEAL_TO_COURT_OF_APPEALS',
+ 'APPEAL_RECEIVED_BY_COURT',
+ 'APPEAL_STATEMENT',
+ 'APPEAL_COMPLETED',
+ 'APPEAL_JUDGES_ASSIGNED',
+ 'APPEAL_CASE_FILES_UPDATED',
+ 'APPEAL_WITHDRAWN',
+ 'INDICTMENT_DENIED',
+ 'INDICTMENT_RETURNED', //new value
+ ],
+ enumName: 'enum_notification_type',
+ })
+ },
+
+ down: (queryInterface) => {
+ // replaceEnum does not support transactions
+ return replaceEnum({
+ queryInterface,
+ tableName: 'notification',
+ columnName: 'type',
+ newValues: [
+ 'HEADS_UP',
+ 'READY_FOR_COURT',
+ 'RECEIVED_BY_COURT',
+ 'COURT_DATE',
+ 'RULING',
+ 'MODIFIED',
+ 'REVOKED',
+ 'DEFENDER_ASSIGNED',
+ 'DEFENDANTS_NOT_UPDATED_AT_COURT',
+ 'APPEAL_TO_COURT_OF_APPEALS',
+ 'APPEAL_RECEIVED_BY_COURT',
+ 'APPEAL_STATEMENT',
+ 'APPEAL_COMPLETED',
+ 'APPEAL_JUDGES_ASSIGNED',
+ 'APPEAL_CASE_FILES_UPDATED',
+ 'APPEAL_WITHDRAWN',
+ 'INDICTMENT_DENIED',
+ ],
+ enumName: 'enum_notification_type',
+ })
+ },
+}
diff --git a/apps/judicial-system/backend/migrations/20240409142123-create-dates.js b/apps/judicial-system/backend/migrations/20240409142123-create-dates.js
new file mode 100644
index 000000000000..51291243dc94
--- /dev/null
+++ b/apps/judicial-system/backend/migrations/20240409142123-create-dates.js
@@ -0,0 +1,88 @@
+'use strict'
+
+module.exports = {
+ up: (queryInterface, Sequelize) => {
+ return queryInterface.sequelize.transaction((t) =>
+ queryInterface
+ .createTable(
+ 'date_log',
+ {
+ id: {
+ type: Sequelize.UUID,
+ primaryKey: true,
+ allowNull: false,
+ defaultValue: Sequelize.UUIDV4,
+ },
+ created: {
+ type: 'TIMESTAMP WITH TIME ZONE',
+ defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
+ allowNull: false,
+ },
+ date_type: {
+ type: Sequelize.STRING,
+ allowNull: false,
+ },
+ case_id: {
+ type: Sequelize.UUID,
+ references: {
+ model: 'case',
+ key: 'id',
+ },
+ allowNull: false,
+ },
+ date: {
+ type: Sequelize.DATE,
+ allowNull: false,
+ },
+ },
+ { transaction: t },
+ )
+ .then(() =>
+ queryInterface.sequelize.query(
+ `insert into "date_log" (id, date_type, case_id, date)
+ select md5(random()::text || clock_timestamp()::text)::uuid,
+ 'COURT_DATE' as date_type,
+ id,
+ court_date
+ from "case"
+ where court_date is not null`,
+ { transaction: t },
+ ),
+ )
+ .then(() =>
+ queryInterface.removeColumn('case', 'court_date', {
+ transaction: t,
+ }),
+ ),
+ )
+ },
+
+ down: (queryInterface, Sequelize) => {
+ return queryInterface.sequelize.transaction((t) =>
+ queryInterface
+ .addColumn(
+ 'case',
+ 'court_date',
+ {
+ type: Sequelize.DATE,
+ allowNull: true,
+ },
+ { transaction: t },
+ )
+ .then(() =>
+ queryInterface.sequelize.query(
+ `update "case"
+ set court_date = d.date
+ from (
+ select distinct on (case_id) *
+ from "date_log"
+ order by case_id
+ ) d
+ where "case".id = d.case_id`,
+ { transaction: t },
+ ),
+ )
+ .then(() => queryInterface.dropTable('date_log', { transaction: t })),
+ )
+ },
+}
diff --git a/apps/judicial-system/backend/migrations/20240416110933-create-robot-log.js b/apps/judicial-system/backend/migrations/20240416110933-create-robot-log.js
new file mode 100644
index 000000000000..51523598975d
--- /dev/null
+++ b/apps/judicial-system/backend/migrations/20240416110933-create-robot-log.js
@@ -0,0 +1,57 @@
+'use strict'
+
+module.exports = {
+ up: (queryInterface, Sequelize) => {
+ return queryInterface.sequelize.transaction((t) =>
+ queryInterface.createTable(
+ 'robot_log',
+ {
+ id: {
+ type: Sequelize.UUID,
+ primaryKey: true,
+ allowNull: false,
+ defaultValue: Sequelize.UUIDV4,
+ },
+ created: {
+ type: 'TIMESTAMP WITH TIME ZONE',
+ defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
+ allowNull: false,
+ },
+ seq_number: {
+ type: Sequelize.INTEGER,
+ defaultValue: Sequelize.literal("nextval('robot_email_seq')"),
+ allowNull: false,
+ },
+ delivered: {
+ type: Sequelize.BOOLEAN,
+ defaultValue: false,
+ allowNull: false,
+ },
+ type: {
+ type: Sequelize.STRING,
+ allowNull: false,
+ },
+ case_id: {
+ type: Sequelize.UUID,
+ references: {
+ model: 'case',
+ key: 'id',
+ },
+ allowNull: false,
+ },
+ element_id: {
+ type: Sequelize.STRING,
+ allowNull: true,
+ },
+ },
+ { transaction: t },
+ ),
+ )
+ },
+
+ down: (queryInterface) => {
+ return queryInterface.sequelize.transaction((t) =>
+ queryInterface.dropTable('robot_log', { transaction: t }),
+ )
+ },
+}
diff --git a/apps/judicial-system/backend/src/app/app.module.ts b/apps/judicial-system/backend/src/app/app.module.ts
index 9e906b044586..d9da37d25faf 100644
--- a/apps/judicial-system/backend/src/app/app.module.ts
+++ b/apps/judicial-system/backend/src/app/app.module.ts
@@ -19,6 +19,7 @@ import {
EventLogModule,
eventModuleConfig,
FileModule,
+ fileModuleConfig,
IndictmentCountModule,
InstitutionModule,
NotificationModule,
@@ -56,6 +57,7 @@ import { SequelizeConfigService } from './sequelizeConfig.service'
courtClientModuleConfig,
messageModuleConfig,
caseModuleConfig,
+ fileModuleConfig,
notificationModuleConfig,
policeModuleConfig,
userModuleConfig,
diff --git a/apps/judicial-system/backend/src/app/formatters/formatters.ts b/apps/judicial-system/backend/src/app/formatters/formatters.ts
index fc777093f1e9..762c11034c55 100644
--- a/apps/judicial-system/backend/src/app/formatters/formatters.ts
+++ b/apps/judicial-system/backend/src/app/formatters/formatters.ts
@@ -230,7 +230,7 @@ export function formatProsecutorReceivedByCourtSmsNotification(
})
}
-export function formatProsecutorCourtDateEmailNotification(
+export const formatProsecutorCourtDateEmailNotification = (
formatMessage: FormatMessage,
type: CaseType,
courtCaseNumber?: string,
@@ -241,7 +241,7 @@ export function formatProsecutorCourtDateEmailNotification(
registrarName?: string,
defenderName?: string,
sessionArrangements?: SessionArrangements,
-): SubjectAndBody {
+): SubjectAndBody => {
const cf = notifications.prosecutorCourtDateEmail
const scheduledCaseText = isIndictmentCase(type)
? formatMessage(cf.sheduledIndictmentCase, { court, courtCaseNumber })
@@ -303,7 +303,7 @@ export function formatProsecutorCourtDateEmailNotification(
return { body, subject }
}
-export function formatPrisonCourtDateEmailNotification(
+export const formatPrisonCourtDateEmailNotification = (
formatMessage: FormatMessage,
type: CaseType,
prosecutorOffice?: string,
@@ -316,7 +316,7 @@ export function formatPrisonCourtDateEmailNotification(
isExtension?: boolean,
sessionArrangements?: SessionArrangements,
courtCaseNumber?: string,
-): string {
+): string => {
const courtText = formatMessage(
notifications.prisonCourtDateEmail.courtText,
{ court: court || 'NONE' },
@@ -546,7 +546,7 @@ export function formatPrisonRevokedEmailNotification(
})
}
-export function formatDefenderRevokedEmailNotification(
+export const formatDefenderRevokedEmailNotification = (
formatMessage: FormatMessage,
type: CaseType,
defendantNationalId?: string,
@@ -554,7 +554,7 @@ export function formatDefenderRevokedEmailNotification(
defendantNoNationalId?: boolean,
court?: string,
courtDate?: Date,
-): string {
+): string => {
const cf = notifications.defenderRevokedEmail
const courtText = formatMessage(cf.court, {
court: court || 'NONE',
diff --git a/apps/judicial-system/backend/src/app/messages/notifications.ts b/apps/judicial-system/backend/src/app/messages/notifications.ts
index 1ce5e7d8253f..046ae5475ec1 100644
--- a/apps/judicial-system/backend/src/app/messages/notifications.ts
+++ b/apps/judicial-system/backend/src/app/messages/notifications.ts
@@ -791,4 +791,18 @@ export const notifications = {
description: 'Texti í pósti til sækjanda máls þegar ákæru er hafnað',
},
}),
+ indictmentReturned: defineMessages({
+ subject: {
+ id: 'judicial.system.backend:notifications.indictment_returned.subject',
+ defaultMessage: 'Ákæra endursend í máli {caseNumber}',
+ description:
+ 'Fyrirsögn í pósti til sækjanda máls þegar ákæra er endursend',
+ },
+ body: {
+ id: 'judicial.system.backend:notifications.indictment_returned.body',
+ defaultMessage:
+ '{courtName} hefur endursent ákæru vegna lögreglumáls {caseNumber}. Þú getur nálgast samantekt málsins á {linkStart}yfirlitssíðu málsins í Réttarvörslugátt.{linkEnd}',
+ description: 'Texti í pósti til sækjanda máls þegar ákæra er endursend',
+ },
+ }),
}
diff --git a/apps/judicial-system/backend/src/app/modules/aws-s3/awsS3.config.ts b/apps/judicial-system/backend/src/app/modules/aws-s3/awsS3.config.ts
index a49dd62f2c1b..de602e758e5a 100644
--- a/apps/judicial-system/backend/src/app/modules/aws-s3/awsS3.config.ts
+++ b/apps/judicial-system/backend/src/app/modules/aws-s3/awsS3.config.ts
@@ -5,7 +5,7 @@ export const awsS3ModuleConfig = defineConfig({
load: (env) => ({
region: env.required('S3_REGION', 'eu-west-1'),
bucket: env.required('S3_BUCKET', 'island-is-dev-upload-judicial-system'),
- timeToLivePost: env.required('S3_TIME_TO_LIVE_POST', '15'),
- timeToLiveGet: env.required('S3_TIME_TO_LIVE_GET', '5'),
+ timeToLivePost: +env.required('S3_TIME_TO_LIVE_POST', '15'), // 15 seconds, convert to number with +
+ timeToLiveGet: +env.required('S3_TIME_TO_LIVE_GET', '5'), // 5 seconds, convert to number with +
}),
})
diff --git a/apps/judicial-system/backend/src/app/modules/aws-s3/awsS3.service.ts b/apps/judicial-system/backend/src/app/modules/aws-s3/awsS3.service.ts
index 979a8fd45f13..3ffe4f13c044 100644
--- a/apps/judicial-system/backend/src/app/modules/aws-s3/awsS3.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/aws-s3/awsS3.service.ts
@@ -22,7 +22,7 @@ export class AwsS3Service {
this.s3.createPresignedPost(
{
Bucket: this.config.bucket,
- Expires: +this.config.timeToLivePost, // convert to number with +
+ Expires: this.config.timeToLivePost,
Fields: {
key,
'content-type': type,
@@ -40,20 +40,20 @@ export class AwsS3Service {
})
}
- getSignedUrl(key: string): Promise<{ url: string }> {
+ getSignedUrl(key: string, timeToLive?: number): Promise {
return new Promise((resolve, reject) => {
this.s3.getSignedUrl(
'getObject',
{
Bucket: this.config.bucket,
Key: key,
- Expires: +this.config.timeToLiveGet, // convert to number with +
+ Expires: timeToLive ?? this.config.timeToLiveGet,
},
(err, url) => {
if (err) {
reject(err)
} else {
- resolve({ url })
+ resolve(url)
}
},
)
diff --git a/apps/judicial-system/backend/src/app/modules/case/case.module.ts b/apps/judicial-system/backend/src/app/modules/case/case.module.ts
index 5c3aab96eb00..782da73bc955 100644
--- a/apps/judicial-system/backend/src/app/modules/case/case.module.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/case.module.ts
@@ -19,6 +19,7 @@ import {
} from '../index'
import { Case } from './models/case.model'
import { CaseArchive } from './models/caseArchive.model'
+import { DateLog } from './models/dateLog.model'
import { CaseController } from './case.controller'
import { CaseService } from './case.service'
import { InternalCaseController } from './internalCase.controller'
@@ -41,7 +42,7 @@ import { PDFService } from './pdf.service'
forwardRef(() => EventModule),
forwardRef(() => PoliceModule),
forwardRef(() => EventLogModule),
- SequelizeModule.forFeature([Case, CaseArchive]),
+ SequelizeModule.forFeature([Case, CaseArchive, DateLog]),
],
providers: [
CaseService,
diff --git a/apps/judicial-system/backend/src/app/modules/case/case.service.ts b/apps/judicial-system/backend/src/app/modules/case/case.service.ts
index 7c229d78fdc7..f1a5ccd1f579 100644
--- a/apps/judicial-system/backend/src/app/modules/case/case.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/case.service.ts
@@ -35,6 +35,7 @@ import {
CaseTransition,
CaseType,
completedCaseStates,
+ DateType,
EventType,
isIndictmentCase,
isRestrictionCase,
@@ -61,6 +62,7 @@ import { User } from '../user'
import { CreateCaseDto } from './dto/createCase.dto'
import { getCasesQueryFilter } from './filters/cases.filter'
import { Case } from './models/case.model'
+import { DateLog } from './models/dateLog.model'
import { SignatureConfirmationResponse } from './models/signatureConfirmation.response'
import { transitionCase } from './state/case.state'
@@ -96,7 +98,6 @@ export interface UpdateCase
| 'sharedWithProsecutorsOfficeId'
| 'courtCaseNumber'
| 'sessionArrangements'
- | 'courtDate'
| 'courtLocation'
| 'courtRoom'
| 'courtStartDate'
@@ -158,9 +159,11 @@ export interface UpdateCase
courtRecordSignatoryId?: string | null
courtRecordSignatureDate?: Date | null
parentCaseId?: string | null
+ courtDate?: Date | null
}
const eventTypes = Object.values(EventType)
+const dateTypes = Object.values(DateType)
export const include: Includeable[] = [
{ model: Institution, as: 'prosecutorsOffice' },
@@ -241,12 +244,19 @@ export const include: Includeable[] = [
where: { eventType: { [Op.in]: eventTypes } },
separate: true,
},
+ {
+ model: DateLog,
+ as: 'dateLogs',
+ required: false,
+ where: { dateType: { [Op.in]: dateTypes } },
+ },
{ model: Notification, as: 'notifications' },
]
export const order: OrderItem[] = [
[{ model: Defendant, as: 'defendants' }, 'created', 'ASC'],
[{ model: IndictmentCount, as: 'indictmentCounts' }, 'created', 'ASC'],
+ [{ model: DateLog, as: 'dateLogs' }, 'created', 'DESC'],
[{ model: Notification, as: 'notifications' }, 'created', 'DESC'],
]
@@ -273,10 +283,17 @@ export const caseListInclude: Includeable[] = [
as: 'registrar',
include: [{ model: Institution, as: 'institution' }],
},
+ {
+ model: DateLog,
+ as: 'dateLogs',
+ required: false,
+ where: { dateType: { [Op.in]: dateTypes } },
+ },
]
export const listOrder: OrderItem[] = [
[{ model: Defendant, as: 'defendants' }, 'created', 'ASC'],
+ [{ model: DateLog, as: 'dateLogs' }, 'created', 'DESC'],
]
@Injectable()
@@ -284,6 +301,7 @@ export class CaseService {
constructor(
@InjectConnection() private readonly sequelize: Sequelize,
@InjectModel(Case) private readonly caseModel: typeof Case,
+ @InjectModel(DateLog) private readonly dateLogModel: typeof DateLog,
private readonly defendantService: DefendantService,
private readonly fileService: FileService,
private readonly awsS3Service: AwsS3Service,
@@ -776,7 +794,7 @@ export class CaseService {
// If case was appealed in court we don't need to send these messages
if (
theCase.accusedAppealDecision === CaseAppealDecision.APPEAL ||
- theCase.prosecutorAppealDecision === CaseAppealDecision.ACCEPT
+ theCase.prosecutorAppealDecision === CaseAppealDecision.APPEAL
) {
return
}
@@ -909,17 +927,50 @@ export class CaseService {
])
}
- private addMessagesForReceivedAppealedCaseToQueue(
+ private addMessagesForReturnedIndictmentCaseToQueue(
theCase: Case,
user: TUser,
): Promise {
- const messages: CaseMessage[] = [
+ return this.messageService.sendMessagesToQueue([
{
- type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_RECEIVED_DATE,
+ type: MessageType.NOTIFICATION,
user,
caseId: theCase.id,
+ body: { type: NotificationType.INDICTMENT_RETURNED },
},
- ]
+ ])
+ }
+
+ private addMessagesForNewAppealCaseNumberToQueue(
+ theCase: Case,
+ user: TUser,
+ ): Promise {
+ const messages: CaseMessage[] =
+ theCase.caseFiles
+ ?.filter(
+ (caseFile) =>
+ caseFile.key &&
+ caseFile.category &&
+ [
+ CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT,
+ CaseFileCategory.DEFENDANT_APPEAL_STATEMENT,
+ CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT_CASE_FILE,
+ CaseFileCategory.DEFENDANT_APPEAL_STATEMENT_CASE_FILE,
+ CaseFileCategory.PROSECUTOR_APPEAL_CASE_FILE,
+ CaseFileCategory.DEFENDANT_APPEAL_CASE_FILE,
+ ].includes(caseFile.category),
+ )
+ .map((caseFile) => ({
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId: theCase.id,
+ elementId: caseFile.id,
+ })) ?? []
+ messages.push({
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_RECEIVED_DATE,
+ user,
+ caseId: theCase.id,
+ })
if (this.allAppealRolesAssigned(theCase)) {
messages.push(
@@ -944,6 +995,7 @@ export class CaseService {
updatedCase: Case,
user: TUser,
): Promise {
+ const isIndictment = isIndictmentCase(updatedCase.type)
if (updatedCase.state !== theCase.state) {
// New case state
if (
@@ -959,7 +1011,7 @@ export class CaseService {
theCase.state,
)
} else if (completedCaseStates.includes(updatedCase.state)) {
- if (isIndictmentCase(updatedCase.type)) {
+ if (isIndictment) {
await this.addMessagesForCompletedIndictmentCaseToQueue(
updatedCase,
user,
@@ -967,25 +1019,29 @@ export class CaseService {
} else {
await this.addMessagesForCompletedCaseToQueue(updatedCase, user)
}
+ } else if (updatedCase.state === CaseState.SUBMITTED && isIndictment) {
+ await this.addMessagesForSubmittedIndicitmentCaseToQueue(
+ updatedCase,
+ user,
+ )
} else if (
- updatedCase.state === CaseState.SUBMITTED &&
- isIndictmentCase(updatedCase.type)
+ updatedCase.state === CaseState.DRAFT &&
+ theCase.state === CaseState.WAITING_FOR_CONFIRMATION &&
+ isIndictment
) {
- await this.addMessagesForSubmittedIndicitmentCaseToQueue(
+ await this.addMessagesForDeniedIndictmentCaseToQueue(updatedCase, user)
+ } else if (
+ updatedCase.state === CaseState.DRAFT &&
+ theCase.state === CaseState.RECEIVED &&
+ isIndictment
+ ) {
+ await this.addMessagesForReturnedIndictmentCaseToQueue(
updatedCase,
user,
)
}
}
- if (
- isIndictmentCase(updatedCase.type) &&
- updatedCase.state === CaseState.DRAFT &&
- theCase.state === CaseState.WAITING_FOR_CONFIRMATION
- ) {
- await this.addMessagesForDeniedIndictmentCaseToQueue(updatedCase, user)
- }
-
// This only applies to restriction cases
if (updatedCase.appealState !== theCase.appealState) {
if (updatedCase.appealState === CaseAppealState.APPEALED) {
@@ -1049,7 +1105,7 @@ export class CaseService {
if (updatedCase.appealCaseNumber) {
if (updatedCase.appealCaseNumber !== theCase.appealCaseNumber) {
// New appeal case number
- await this.addMessagesForReceivedAppealedCaseToQueue(updatedCase, user)
+ await this.addMessagesForNewAppealCaseNumberToQueue(updatedCase, user)
} else if (
this.allAppealRolesAssigned(updatedCase) &&
(updatedCase.appealAssistantId !== theCase.appealAssistantId ||
@@ -1121,6 +1177,25 @@ export class CaseService {
.then((caseId) => this.findById(caseId))
}
+ private async handleDateUpdates(
+ theCase: Case,
+ update: UpdateCase,
+ transaction: Transaction,
+ ) {
+ if (update.courtDate) {
+ await this.dateLogModel.create(
+ {
+ dateType: DateType.COURT_DATE,
+ caseId: theCase.id,
+ date: update.courtDate,
+ },
+ { transaction },
+ )
+
+ delete update.courtDate
+ }
+ }
+
async update(
theCase: Case,
update: UpdateCase,
@@ -1140,6 +1215,12 @@ export class CaseService {
).state
}
+ await this.handleDateUpdates(theCase, update, transaction)
+
+ if (Object.keys(update).length === 0) {
+ return
+ }
+
const [numberOfAffectedRows] = await this.caseModel.update(update, {
where: { id: theCase.id },
transaction,
diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts b/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts
index 68726ac22bb4..a2943119be1b 100644
--- a/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts
@@ -4,6 +4,8 @@ import {
CaseDecision,
CaseState,
CaseType,
+ DateType,
+ getLatestDateType,
InstitutionType,
isCourtOfAppealsUser,
isDefenceUser,
@@ -19,11 +21,11 @@ import {
import { Case } from '../models/case.model'
-function canProsecutionUserAccessCase(
+const canProsecutionUserAccessCase = (
theCase: Case,
user: User,
forUpdate = true,
-): boolean {
+): boolean => {
// Check case type access
if (user.role !== UserRole.PROSECUTOR && !isIndictmentCase(theCase.type)) {
return false
@@ -66,7 +68,7 @@ function canProsecutionUserAccessCase(
return true
}
-function canDistrictCourtUserAccessCase(theCase: Case, user: User): boolean {
+const canDistrictCourtUserAccessCase = (theCase: Case, user: User): boolean => {
// Check case type access
if (
![
@@ -113,7 +115,7 @@ function canDistrictCourtUserAccessCase(theCase: Case, user: User): boolean {
return true
}
-function canAppealsCourtUserAccessCase(theCase: Case): boolean {
+const canAppealsCourtUserAccessCase = (theCase: Case): boolean => {
// Check case type access
if (!isRestrictionCase(theCase.type) && !isInvestigationCase(theCase.type)) {
return false
@@ -150,11 +152,11 @@ function canAppealsCourtUserAccessCase(theCase: Case): boolean {
return true
}
-function canPrisonSystemUserAccessCase(
+const canPrisonSystemUserAccessCase = (
theCase: Case,
user: User,
forUpdate = true,
-): boolean {
+): boolean => {
// Prison system users cannot update cases
if (forUpdate) {
return false
@@ -209,7 +211,7 @@ function canPrisonSystemUserAccessCase(
return true
}
-function canDefenceUserAccessCase(theCase: Case, user: User): boolean {
+const canDefenceUserAccessCase = (theCase: Case, user: User): boolean => {
// Check case state access
if (
![
@@ -223,6 +225,8 @@ function canDefenceUserAccessCase(theCase: Case, user: User): boolean {
return false
}
+ const courtDate = getLatestDateType(DateType.COURT_DATE, theCase.dateLogs)
+
// Check submitted case access
const canDefenderAccessSubmittedCase =
(isRestrictionCase(theCase.type) || isInvestigationCase(theCase.type)) &&
@@ -241,7 +245,7 @@ function canDefenceUserAccessCase(theCase: Case, user: User): boolean {
const canDefenderAccessReceivedCase =
isIndictmentCase(theCase.type) ||
canDefenderAccessSubmittedCase ||
- Boolean(theCase.courtDate)
+ Boolean(courtDate)
if (!canDefenderAccessReceivedCase) {
return false
@@ -266,11 +270,11 @@ function canDefenceUserAccessCase(theCase: Case, user: User): boolean {
return true
}
-export function canUserAccessCase(
+export const canUserAccessCase = (
theCase: Case,
user: User,
forUpdate = true,
-): boolean {
+): boolean => {
if (isProsecutionUser(user)) {
return canProsecutionUserAccessCase(theCase, user, forUpdate)
}
diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts b/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts
index 808dafca45d3..a236f6984f40 100644
--- a/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts
@@ -22,7 +22,7 @@ import {
UserRole,
} from '@island.is/judicial-system/types'
-function getProsecutionUserCasesQueryFilter(user: User): WhereOptions {
+const getProsecutionUserCasesQueryFilter = (user: User): WhereOptions => {
const options: WhereOptions = [
{ isArchived: false },
{
@@ -66,7 +66,7 @@ function getProsecutionUserCasesQueryFilter(user: User): WhereOptions {
}
}
-function getDistrictCourtUserCasesQueryFilter(user: User): WhereOptions {
+const getDistrictCourtUserCasesQueryFilter = (user: User): WhereOptions => {
const options: WhereOptions = [
{ isArchived: false },
{
@@ -131,7 +131,7 @@ function getDistrictCourtUserCasesQueryFilter(user: User): WhereOptions {
}
}
-function getAppealsCourtUserCasesQueryFilter(): WhereOptions {
+const getAppealsCourtUserCasesQueryFilter = (): WhereOptions => {
return {
[Op.and]: [
{ isArchived: false },
@@ -154,7 +154,7 @@ function getAppealsCourtUserCasesQueryFilter(): WhereOptions {
}
}
-function getPrisonSystemStaffUserCasesQueryFilter(user: User): WhereOptions {
+const getPrisonSystemStaffUserCasesQueryFilter = (user: User): WhereOptions => {
const options: WhereOptions = [
{ isArchived: false },
{ state: CaseState.ACCEPTED },
@@ -187,7 +187,7 @@ function getPrisonSystemStaffUserCasesQueryFilter(user: User): WhereOptions {
return { [Op.and]: options }
}
-function getDefenceUserCasesQueryFilter(user: User): WhereOptions {
+const getDefenceUserCasesQueryFilter = (user: User): WhereOptions => {
const options: WhereOptions = [
{ isArchived: false },
{
@@ -209,7 +209,7 @@ function getDefenceUserCasesQueryFilter(user: User): WhereOptions {
{
[Op.and]: [
{ state: CaseState.RECEIVED },
- { court_date: { [Op.not]: null } },
+ { '$dateLogs.date_type$': 'COURT_DATE' },
],
},
{ state: completedCaseStates },
@@ -236,7 +236,7 @@ function getDefenceUserCasesQueryFilter(user: User): WhereOptions {
}
}
-export function getCasesQueryFilter(user: User): WhereOptions {
+export const getCasesQueryFilter = (user: User): WhereOptions => {
// TODO: Convert to switch
if (isProsecutionUser(user)) {
return getProsecutionUserCasesQueryFilter(user)
diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts b/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts
index 66795cd97a71..c8adfbeb1c38 100644
--- a/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts
@@ -375,7 +375,7 @@ describe('getCasesQueryFilter', () => {
{
[Op.and]: [
{ state: CaseState.RECEIVED },
- { court_date: { [Op.not]: null } },
+ { '$dateLogs.date_type$': 'COURT_DATE' },
],
},
{ state: completedCaseStates },
diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/test/defenceUserFilter.spec.ts b/apps/judicial-system/backend/src/app/modules/case/filters/test/defenceUserFilter.spec.ts
index 42ad96388caa..c0eb84d45498 100644
--- a/apps/judicial-system/backend/src/app/modules/case/filters/test/defenceUserFilter.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/filters/test/defenceUserFilter.spec.ts
@@ -3,6 +3,7 @@ import { uuid } from 'uuidv4'
import {
CaseState,
completedCaseStates,
+ DateType,
defenceRoles,
indictmentCases,
investigationCases,
@@ -105,7 +106,7 @@ describe.each(defenceRoles)('defence user %s', (role) => {
state: CaseState.RECEIVED,
requestSharedWithDefender:
RequestSharedWithDefender.READY_FOR_COURT,
- courtDate: new Date(),
+ dateLogs: [{ dateType: DateType.COURT_DATE, date: new Date() }],
} as Case
verifyNoAccess(theCase, user)
@@ -128,7 +129,7 @@ describe.each(defenceRoles)('defence user %s', (role) => {
type,
state: CaseState.RECEIVED,
defenderNationalId: user.nationalId,
- courtDate: new Date(),
+ dateLogs: [{ dateType: DateType.COURT_DATE, date: new Date() }],
} as Case
verifyFullAccess(theCase, user)
diff --git a/apps/judicial-system/backend/src/app/modules/case/guards/rolesRules.ts b/apps/judicial-system/backend/src/app/modules/case/guards/rolesRules.ts
index c2a4a2e30038..3e806159c065 100644
--- a/apps/judicial-system/backend/src/app/modules/case/guards/rolesRules.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/guards/rolesRules.ts
@@ -282,7 +282,7 @@ export const districtCourtJudgeTransitionRule: RolesRule = {
canActivate: (request) => {
const theCase = request.case
- // Deny if the case is missing - shuould never happen
+ // Deny if the case is missing - should never happen
if (!theCase) {
return false
}
diff --git a/apps/judicial-system/backend/src/app/modules/case/index.ts b/apps/judicial-system/backend/src/app/modules/case/index.ts
index a65a80d18a51..6e87ecffd09b 100644
--- a/apps/judicial-system/backend/src/app/modules/case/index.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/index.ts
@@ -1,4 +1,5 @@
export { Case } from './models/case.model'
+export { DateLog } from './models/dateLog.model'
export { CaseExistsGuard } from './guards/caseExists.guard'
export { LimitedAccessCaseExistsGuard } from './guards/limitedAccessCaseExists.guard'
export { CaseHasExistedGuard } from './guards/caseHasExisted.guard'
diff --git a/apps/judicial-system/backend/src/app/modules/case/interceptors/caseList.interceptor.ts b/apps/judicial-system/backend/src/app/modules/case/interceptors/caseList.interceptor.ts
index df3ba9ccc437..2db873d0876b 100644
--- a/apps/judicial-system/backend/src/app/modules/case/interceptors/caseList.interceptor.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/interceptors/caseList.interceptor.ts
@@ -8,8 +8,11 @@ import {
NestInterceptor,
} from '@nestjs/common'
+import { DateType, getLatestDateType } from '@island.is/judicial-system/types'
+
import { Case } from '../models/case.model'
import { CaseListEntry } from '../models/caseListEntry.response'
+import { DateLog } from '../models/dateLog.model'
@Injectable()
export class CaseListInterceptor implements NestInterceptor {
@@ -18,11 +21,17 @@ export class CaseListInterceptor implements NestInterceptor {
next: CallHandler,
): Observable {
return next.handle().pipe(
- map((cases: Case[]) => {
- return cases.map((theCase) => {
+ map((cases: Case[]) =>
+ cases.map((theCase) => {
// WARNING: Be careful when adding to this list. No sensitive information should be returned.
// If you need to add sensitive information, then you should consider adding a new endpoint
// for defenders and other user roles that are not allowed to see sensitive information.
+
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ theCase.dateLogs,
+ ) as DateLog
+
return {
id: theCase.id,
created: theCase.created,
@@ -33,7 +42,7 @@ export class CaseListInterceptor implements NestInterceptor {
courtCaseNumber: theCase.courtCaseNumber,
decision: theCase.decision,
validToDate: theCase.validToDate,
- courtDate: theCase.courtDate,
+ courtDate: courtDate?.date,
initialRulingDate: theCase.initialRulingDate,
rulingDate: theCase.rulingDate,
rulingSignatureDate: theCase.rulingSignatureDate,
@@ -53,8 +62,8 @@ export class CaseListInterceptor implements NestInterceptor {
appealRulingDecision: theCase.appealRulingDecision,
prosecutorsOffice: theCase.prosecutorsOffice,
}
- })
- }),
+ }),
+ ),
)
}
}
diff --git a/apps/judicial-system/backend/src/app/modules/case/internalCase.service.ts b/apps/judicial-system/backend/src/app/modules/case/internalCase.service.ts
index 18cba596043c..a78cb6307567 100644
--- a/apps/judicial-system/backend/src/app/modules/case/internalCase.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/internalCase.service.ts
@@ -721,6 +721,7 @@ export class InternalCaseService {
theCase.id,
theCase.court?.name,
theCase.courtCaseNumber,
+ Boolean(theCase.rulingModifiedHistory),
theCase.decision,
theCase.rulingDate,
isRestrictionCase(theCase.type) ? theCase.validToDate : undefined,
@@ -754,9 +755,7 @@ export class InternalCaseService {
.catch((reason) => {
this.logger.error(
`Failed to update appeal case ${theCase.id} with received date`,
- {
- reason,
- },
+ { reason },
)
return { delivered: false }
@@ -781,9 +780,7 @@ export class InternalCaseService {
.catch((reason) => {
this.logger.error(
`Failed to update appeal case ${theCase.id} with assigned roles`,
- {
- reason,
- },
+ { reason },
)
return { delivered: false }
@@ -818,9 +815,7 @@ export class InternalCaseService {
.catch((reason) => {
this.logger.error(
`Failed to update appeal case ${theCase.id} with conclusion`,
- {
- reason,
- },
+ { reason },
)
return { delivered: false }
@@ -854,6 +849,7 @@ export class InternalCaseService {
theCase.type,
theCase.state,
theCase.policeCaseNumbers.length > 0 ? theCase.policeCaseNumbers[0] : '',
+ theCase.courtCaseNumber ?? '',
defendantNationalIds && defendantNationalIds[0]
? defendantNationalIds[0].replace('-', '')
: '',
diff --git a/apps/judicial-system/backend/src/app/modules/case/limitedAccessCase.service.ts b/apps/judicial-system/backend/src/app/modules/case/limitedAccessCase.service.ts
index 5f9827de8928..5337d4f0edc8 100644
--- a/apps/judicial-system/backend/src/app/modules/case/limitedAccessCase.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/limitedAccessCase.service.ts
@@ -24,6 +24,7 @@ import {
CaseFileCategory,
CaseFileState,
CaseState,
+ DateType,
NotificationType,
UserRole,
} from '@island.is/judicial-system/types'
@@ -38,6 +39,7 @@ import {
import { Institution } from '../institution'
import { User } from '../user'
import { Case } from './models/case.model'
+import { DateLog } from './models/dateLog.model'
import { PDFService } from './pdf.service'
export const attributes: (keyof Case)[] = [
@@ -59,7 +61,6 @@ export const attributes: (keyof Case)[] = [
'requestedCustodyRestrictions',
'prosecutorId',
'courtCaseNumber',
- 'courtDate',
'courtEndTime',
'decision',
'validToDate',
@@ -106,6 +107,8 @@ export interface LimitedAccessUpdateCase
| 'appealRulingDecision'
> {}
+const dateTypes = Object.values(DateType)
+
export const include: Includeable[] = [
{ model: Institution, as: 'prosecutorsOffice' },
{ model: Institution, as: 'court' },
@@ -178,10 +181,17 @@ export const include: Includeable[] = [
],
},
},
+ {
+ model: DateLog,
+ as: 'dateLogs',
+ required: false,
+ where: { dateType: { [Op.in]: dateTypes } },
+ },
]
export const order: OrderItem[] = [
[{ model: Defendant, as: 'defendants' }, 'created', 'ASC'],
+ [{ model: DateLog, as: 'dateLogs' }, 'created', 'DESC'],
]
@Injectable()
diff --git a/apps/judicial-system/backend/src/app/modules/case/models/case.model.ts b/apps/judicial-system/backend/src/app/modules/case/models/case.model.ts
index caa2b31cb07f..ef8459eaa619 100644
--- a/apps/judicial-system/backend/src/app/modules/case/models/case.model.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/models/case.model.ts
@@ -40,6 +40,7 @@ import { IndictmentCount } from '../../indictment-count'
import { Institution } from '../../institution'
import { Notification } from '../../notification'
import { User } from '../../user'
+import { DateLog } from './dateLog.model'
@Table({
tableName: 'case',
@@ -479,16 +480,6 @@ export class Case extends Model {
@ApiPropertyOptional({ enum: SessionArrangements })
sessionArrangements?: SessionArrangements
- /**********
- * The scheduled date and time of the case's court session
- **********/
- @Column({
- type: DataType.DATE,
- allowNull: true,
- })
- @ApiPropertyOptional()
- courtDate?: Date
-
/**********
* The location of the court session
**********/
@@ -1137,6 +1128,13 @@ export class Case extends Model {
@ApiPropertyOptional({ type: EventLog, isArray: true })
eventLogs?: EventLog[]
+ /**********
+ * The case's date logs
+ **********/
+ @HasMany(() => DateLog, 'caseId')
+ @ApiPropertyOptional({ type: DateLog, isArray: true })
+ dateLogs?: DateLog[]
+
/**********
* The appeal ruling expiration date and time - example: the end of custody in custody cases -
* autofilled from validToDate - possibly modified by the court of appeals - only used for
diff --git a/apps/judicial-system/backend/src/app/modules/case/models/dateLog.model.ts b/apps/judicial-system/backend/src/app/modules/case/models/dateLog.model.ts
new file mode 100644
index 000000000000..723581e2c0a2
--- /dev/null
+++ b/apps/judicial-system/backend/src/app/modules/case/models/dateLog.model.ts
@@ -0,0 +1,46 @@
+import {
+ Column,
+ CreatedAt,
+ DataType,
+ ForeignKey,
+ Model,
+ Table,
+} from 'sequelize-typescript'
+
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
+
+import { DateType } from '@island.is/judicial-system/types'
+
+import { Case } from './case.model'
+
+@Table({
+ tableName: 'date_log',
+ timestamps: false,
+})
+export class DateLog extends Model {
+ @Column({
+ type: DataType.UUID,
+ primaryKey: true,
+ defaultValue: DataType.UUIDV4,
+ })
+ @ApiProperty()
+ id!: string
+
+ @CreatedAt
+ @Column({ type: DataType.DATE })
+ @ApiProperty()
+ created!: Date
+
+ @Column({ type: DataType.STRING })
+ @ApiProperty()
+ dateType!: DateType
+
+ @ForeignKey(() => Case)
+ @Column({ type: DataType.UUID })
+ @ApiPropertyOptional()
+ caseId!: string
+
+ @Column({ type: DataType.DATE })
+ @ApiPropertyOptional()
+ date!: Date
+}
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/createCourtCase.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/createCourtCase.spec.ts
index 6da6c25daf34..089aad1a86bf 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/createCourtCase.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/createCourtCase.spec.ts
@@ -7,7 +7,6 @@ import {
CaseFileState,
CaseState,
CaseType,
- indictmentCases,
IndictmentSubtype,
investigationCases,
isIndictmentCase,
@@ -130,16 +129,10 @@ describe('CaseController - Create court case', () => {
false,
indictmentSubtypes,
)
- })
-
- it('should update the court case number', () => {
expect(mockCaseModel.update).toHaveBeenCalledWith(
{ courtCaseNumber },
{ where: { id: caseId }, transaction },
)
- })
-
- it('should lookup the updated case', () => {
expect(mockCaseModel.findOne).toHaveBeenCalledWith({
include,
order,
@@ -148,14 +141,11 @@ describe('CaseController - Create court case', () => {
isArchived: false,
},
})
- })
-
- it('should return the case', () => {
expect(then.result).toBe(returnedCase)
})
})
- describe('court case received', () => {
+ describe('case transitioned from SUBMITTED to RECEIVED', () => {
const caseId = uuid()
const type = randomEnum(CaseType)
const courtId = uuid()
@@ -186,59 +176,56 @@ describe('CaseController - Create court case', () => {
})
})
- describe.each([...restrictionCases, ...investigationCases])(
- '%s case queued',
- (type) => {
- const defendantId1 = uuid()
- const defendantId2 = uuid()
- const caseId = uuid()
- const theCase = {
- id: caseId,
- } as Case
- const returnedCase = {
- id: caseId,
- type,
- defendants: [{ id: defendantId1 }, { id: defendantId2 }],
- courtCaseNumber,
- } as Case
+ describe('R-case queued', () => {
+ const defendantId1 = uuid()
+ const defendantId2 = uuid()
+ const caseId = uuid()
+ const theCase = {
+ id: caseId,
+ } as Case
+ const returnedCase = {
+ id: caseId,
+ type: randomEnum([...restrictionCases, ...investigationCases]),
+ defendants: [{ id: defendantId1 }, { id: defendantId2 }],
+ courtCaseNumber,
+ } as Case
- beforeEach(async () => {
- const mockFindOne = mockCaseModel.findOne as jest.Mock
- mockFindOne.mockResolvedValueOnce(returnedCase)
+ beforeEach(async () => {
+ const mockFindOne = mockCaseModel.findOne as jest.Mock
+ mockFindOne.mockResolvedValueOnce(returnedCase)
- await givenWhenThen(caseId, user, theCase)
- })
+ await givenWhenThen(caseId, user, theCase)
+ })
- it('should post to queue', () => {
- expect(mockMessageService.sendMessagesToQueue).toHaveBeenCalledWith([
- {
- type: MessageType.DELIVERY_TO_COURT_REQUEST,
- user,
- caseId,
- },
- {
- type: MessageType.DELIVERY_TO_COURT_PROSECUTOR,
- user,
- caseId,
- },
- {
- type: MessageType.DELIVERY_TO_COURT_DEFENDANT,
- user,
- caseId,
- elementId: defendantId1,
- },
- {
- type: MessageType.DELIVERY_TO_COURT_DEFENDANT,
- user,
- caseId,
- elementId: defendantId2,
- },
- ])
- })
- },
- )
+ it('should post to queue', () => {
+ expect(mockMessageService.sendMessagesToQueue).toHaveBeenCalledWith([
+ {
+ type: MessageType.DELIVERY_TO_COURT_REQUEST,
+ user,
+ caseId,
+ },
+ {
+ type: MessageType.DELIVERY_TO_COURT_PROSECUTOR,
+ user,
+ caseId,
+ },
+ {
+ type: MessageType.DELIVERY_TO_COURT_DEFENDANT,
+ user,
+ caseId,
+ elementId: defendantId1,
+ },
+ {
+ type: MessageType.DELIVERY_TO_COURT_DEFENDANT,
+ user,
+ caseId,
+ elementId: defendantId2,
+ },
+ ])
+ })
+ })
- describe.each(indictmentCases)('%s case queued', (type) => {
+ describe('indictment case queued', () => {
const caseId = uuid()
const policeCaseNumber1 = uuid()
const policeCaseNumber2 = uuid()
@@ -252,7 +239,7 @@ describe('CaseController - Create court case', () => {
} as Case
const returnedCase = {
id: caseId,
- type,
+ type: CaseType.INDICTMENT,
policeCaseNumbers: [policeCaseNumber1, policeCaseNumber2],
caseFiles: [
{
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/createCourtCaseGuards.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/createCourtCaseGuards.spec.ts
index 069d1f4f81ea..a2c77035e66d 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/createCourtCaseGuards.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/createCourtCaseGuards.spec.ts
@@ -1,5 +1,3 @@
-import { CanActivate } from '@nestjs/common'
-
import { JwtAuthGuard, RolesGuard } from '@island.is/judicial-system/auth'
import { CaseController } from '../../case.controller'
@@ -17,55 +15,11 @@ describe('CaseController - Create court case guards', () => {
)
})
- it('should have four guards', () => {
+ it('should have the right guard configuration', () => {
expect(guards).toHaveLength(4)
- })
-
- describe('JwtAuthGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[0]()
- })
-
- it('should have JwtAuthGuard as guard 1', () => {
- expect(guard).toBeInstanceOf(JwtAuthGuard)
- })
- })
-
- describe('RolesGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[1]()
- })
-
- it('should have RolesGuard as guard 2', () => {
- expect(guard).toBeInstanceOf(RolesGuard)
- })
- })
-
- describe('CaseExistsGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[2]()
- })
-
- it('should have CaseExistsGuard as guard 3', () => {
- expect(guard).toBeInstanceOf(CaseExistsGuard)
- })
- })
-
- describe('CaseWriteGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[3]()
- })
-
- it('should have CaseWriteGuard as guard 4', () => {
- expect(guard).toBeInstanceOf(CaseWriteGuard)
- })
+ expect(new guards[0]()).toBeInstanceOf(JwtAuthGuard)
+ expect(new guards[1]()).toBeInstanceOf(RolesGuard)
+ expect(new guards[2]()).toBeInstanceOf(CaseExistsGuard)
+ expect(new guards[3]()).toBeInstanceOf(CaseWriteGuard)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/createGuards.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/createGuards.spec.ts
index 0ed120444841..339e82a1704d 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/createGuards.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/createGuards.spec.ts
@@ -1,5 +1,3 @@
-import { CanActivate } from '@nestjs/common'
-
import { JwtAuthGuard, RolesGuard } from '@island.is/judicial-system/auth'
import { CaseController } from '../../case.controller'
@@ -12,31 +10,9 @@ describe('CaseController - Create guards', () => {
guards = Reflect.getMetadata('__guards__', CaseController.prototype.create)
})
- it('should have two guards', () => {
+ it('should have the right guard configuration', () => {
expect(guards).toHaveLength(2)
- })
-
- describe('JwtAuthGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[0]()
- })
-
- it('should have JwtAuthGuard as guard 1', () => {
- expect(guard).toBeInstanceOf(JwtAuthGuard)
- })
- })
-
- describe('RolesGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[1]()
- })
-
- it('should have RolesGuard as guard 2', () => {
- expect(guard).toBeInstanceOf(RolesGuard)
- })
+ expect(new guards[0]()).toBeInstanceOf(JwtAuthGuard)
+ expect(new guards[1]()).toBeInstanceOf(RolesGuard)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/extend.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/extend.spec.ts
index 343069d965f9..2c61fb445b4e 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/extend.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/extend.spec.ts
@@ -89,6 +89,20 @@ describe('CaseController - Extend', () => {
const requestProsecutorOnlySession = false
const prosecutorOnlySessionRequest = 'The prosecutors wants an exclusive'
const rulingDate = randomDate()
+ const defendantOne = {
+ nationalId: '0000000000',
+ name: 'Thing 1',
+ gender: Gender.MALE,
+ address: 'House 1',
+ citizenship: 'Citizenship 1',
+ }
+ const defendantTwo = {
+ nationalId: '0000001111',
+ name: 'Thing 2',
+ gender: Gender.FEMALE,
+ address: 'House 2',
+ citizenship: 'Citizenship 2',
+ }
const theCase = {
id: caseId,
origin,
@@ -111,9 +125,15 @@ describe('CaseController - Extend', () => {
requestProsecutorOnlySession,
prosecutorOnlySessionRequest,
rulingDate,
+ defendants: [defendantOne, defendantTwo],
} as Case
+ const extendedCaseId = uuid()
+ const extendedCase = { id: extendedCaseId }
beforeEach(async () => {
+ const mockCreate = mockCaseModel.create as jest.Mock
+ mockCreate.mockResolvedValueOnce(extendedCase)
+
await givenWhenThen(caseId, user, theCase)
})
@@ -147,6 +167,49 @@ describe('CaseController - Extend', () => {
},
{ transaction },
)
+ expect(mockDefendantService.createForNewCase).toHaveBeenCalledTimes(2)
+ expect(mockDefendantService.createForNewCase).toHaveBeenCalledWith(
+ extendedCaseId,
+ defendantOne,
+ transaction,
+ )
+ expect(mockDefendantService.createForNewCase).toHaveBeenCalledWith(
+ extendedCaseId,
+ defendantTwo,
+ transaction,
+ )
+ expect(mockCaseModel.findOne).toHaveBeenCalledWith({
+ include,
+ order,
+ where: {
+ id: extendedCaseId,
+ isArchived: false,
+ state: { [Op.not]: CaseState.DELETED },
+ },
+ })
+ })
+ })
+
+ describe('case returned', () => {
+ const user = {} as TUser
+ const caseId = uuid()
+ const theCase = { id: caseId } as Case
+ const extendedCaseId = uuid()
+ const extendedCase = { id: extendedCaseId }
+ const returnedCase = {} as Case
+ let then: Then
+
+ beforeEach(async () => {
+ const mockCreate = mockCaseModel.create as jest.Mock
+ mockCreate.mockResolvedValueOnce(extendedCase)
+ const mockFindOne = mockCaseModel.findOne as jest.Mock
+ mockFindOne.mockResolvedValueOnce(returnedCase)
+
+ then = await givenWhenThen(caseId, user, theCase)
+ })
+
+ it('should return case', () => {
+ expect(then.result).toBe(returnedCase)
})
})
@@ -200,6 +263,7 @@ describe('CaseController - Extend', () => {
requestProsecutorOnlySession,
prosecutorOnlySessionRequest,
initialRulingDate,
+ parentCaseId: uuid(),
} as Case
beforeEach(async () => {
@@ -239,104 +303,8 @@ describe('CaseController - Extend', () => {
})
})
- describe('copy defendants', () => {
- const user = {} as TUser
- const caseId = uuid()
- const defendantOne = {
- nationalId: '0000000000',
- name: 'Thing 1',
- gender: Gender.MALE,
- address: 'House 1',
- citizenship: 'Citizenship 1',
- }
- const defendantTwo = {
- nationalId: '0000001111',
- name: 'Thing 2',
- gender: Gender.FEMALE,
- address: 'House 2',
- citizenship: 'Citizenship 2',
- }
- const theCase = {
- id: caseId,
- defendants: [defendantOne, defendantTwo],
- } as Case
- const extendedCaseId = uuid()
- const extendedCase = { id: extendedCaseId }
-
- beforeEach(async () => {
- const mockCreate = mockCaseModel.create as jest.Mock
- mockCreate.mockResolvedValueOnce(extendedCase)
-
- await givenWhenThen(caseId, user, theCase)
- })
-
- it('should copy defendants', () => {
- expect(mockDefendantService.createForNewCase).toHaveBeenCalledTimes(2)
- expect(mockDefendantService.createForNewCase).toHaveBeenCalledWith(
- extendedCaseId,
- defendantOne,
- transaction,
- )
- expect(mockDefendantService.createForNewCase).toHaveBeenCalledWith(
- extendedCaseId,
- defendantTwo,
- transaction,
- )
- })
- })
-
- describe('case lookup', () => {
- const user = {} as TUser
- const caseId = uuid()
- const theCase = { id: caseId } as Case
- const extendedCaseId = uuid()
- const extendedCase = { id: extendedCaseId }
-
- beforeEach(async () => {
- const mockCreate = mockCaseModel.create as jest.Mock
- mockCreate.mockResolvedValueOnce(extendedCase)
-
- await givenWhenThen(caseId, user, theCase)
- })
-
- it('should lookup the newly extended case', () => {
- expect(mockCaseModel.findOne).toHaveBeenCalledWith({
- include,
- order,
- where: {
- id: extendedCaseId,
- isArchived: false,
- state: { [Op.not]: CaseState.DELETED },
- },
- })
- })
- })
-
- describe('case returned', () => {
- const user = {} as TUser
- const caseId = uuid()
- const theCase = { id: caseId } as Case
- const extendedCaseId = uuid()
- const extendedCase = { id: extendedCaseId }
- const returnedCase = {} as Case
- let then: Then
-
- beforeEach(async () => {
- const mockCreate = mockCaseModel.create as jest.Mock
- mockCreate.mockResolvedValueOnce(extendedCase)
- const mockFindOne = mockCaseModel.findOne as jest.Mock
- mockFindOne.mockResolvedValueOnce(returnedCase)
-
- then = await givenWhenThen(caseId, user, theCase)
- })
-
- it('should return case', () => {
- expect(then.result).toBe(returnedCase)
- })
- })
-
describe('case creation fails', () => {
- const user = {} as TUser
+ const user = { id: uuid() } as TUser
const caseId = uuid()
const theCase = { id: caseId } as Case
let then: Then
@@ -355,7 +323,7 @@ describe('CaseController - Extend', () => {
})
describe('defendant creation fails', () => {
- const user = {} as TUser
+ const user = { id: uuid() } as TUser
const caseId = uuid()
const theCase = { id: caseId, defendants: [{}] } as Case
const extendedCaseId = uuid()
@@ -379,7 +347,7 @@ describe('CaseController - Extend', () => {
})
describe('case lookup fails', () => {
- const user = {} as TUser
+ const user = { id: uuid() } as TUser
const caseId = uuid()
const theCase = { id: caseId } as Case
const extendedCaseId = uuid()
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/extendGuards.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/extendGuards.spec.ts
index 2d841536a936..334b0ad2fbf2 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/extendGuards.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/extendGuards.spec.ts
@@ -1,5 +1,3 @@
-import { CanActivate } from '@nestjs/common'
-
import { JwtAuthGuard, RolesGuard } from '@island.is/judicial-system/auth'
import {
investigationCases,
@@ -19,70 +17,15 @@ describe('CaseController - Extend guards', () => {
guards = Reflect.getMetadata('__guards__', CaseController.prototype.extend)
})
- it('should have five guards', () => {
+ it('should have the right guard configuration', () => {
expect(guards).toHaveLength(5)
- })
-
- describe('JwtAuthGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[0]()
- })
-
- it('should have JwtAuthGuard as guard 1', () => {
- expect(guard).toBeInstanceOf(JwtAuthGuard)
- })
- })
-
- describe('RolesGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[1]()
- })
-
- it('should have RolesGuard as guard 2', () => {
- expect(guard).toBeInstanceOf(RolesGuard)
- })
- })
-
- describe('CaseExistsGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[2]()
- })
-
- it('should have CaseExistsGuard as guard 3', () => {
- expect(guard).toBeInstanceOf(CaseExistsGuard)
- })
- })
-
- describe('CaseTypeGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = guards[3]
- })
-
- it('should have CaseTypeGuard as guard 4', () => {
- expect(guard).toBeInstanceOf(CaseTypeGuard)
- expect(guard).toEqual({
- allowedCaseTypes: [...restrictionCases, ...investigationCases],
- })
- })
- })
-
- describe('CaseReadGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[4]()
- })
-
- it('should have CaseReadGuard as guard 5', () => {
- expect(guard).toBeInstanceOf(CaseReadGuard)
- })
+ expect(new guards[0]()).toBeInstanceOf(JwtAuthGuard)
+ expect(new guards[1]()).toBeInstanceOf(RolesGuard)
+ expect(new guards[2]()).toBeInstanceOf(CaseExistsGuard)
+ expect(guards[3]).toBeInstanceOf(CaseTypeGuard)
+ expect(guards[3]).toEqual({
+ allowedCaseTypes: [...restrictionCases, ...investigationCases],
+ })
+ expect(new guards[4]()).toBeInstanceOf(CaseReadGuard)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getAllGuards.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getAllGuards.spec.ts
index b627b9bfbe1b..ae164cbb9cd5 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getAllGuards.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getAllGuards.spec.ts
@@ -1,5 +1,3 @@
-import { CanActivate } from '@nestjs/common'
-
import { JwtAuthGuard, RolesGuard } from '@island.is/judicial-system/auth'
import { CaseController } from '../../case.controller'
@@ -12,31 +10,9 @@ describe('CaseController - Get all guards', () => {
guards = Reflect.getMetadata('__guards__', CaseController.prototype.getAll)
})
- it('should have two guards', () => {
+ it('should have the right guard configuration', () => {
expect(guards).toHaveLength(2)
- })
-
- describe('JwtAuthGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[0]()
- })
-
- it('should have JwtAuthGuard as guard 1', () => {
- expect(guard).toBeInstanceOf(JwtAuthGuard)
- })
- })
-
- describe('RolesGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[1]()
- })
-
- it('should have RolesGuard as guard 2', () => {
- expect(guard).toBeInstanceOf(RolesGuard)
- })
+ expect(new guards[0]()).toBeInstanceOf(JwtAuthGuard)
+ expect(new guards[1]()).toBeInstanceOf(RolesGuard)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getByIdGuards.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getByIdGuards.spec.ts
index ef1fc9de9634..f5544f0ae24a 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getByIdGuards.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getByIdGuards.spec.ts
@@ -1,5 +1,3 @@
-import { CanActivate } from '@nestjs/common'
-
import { JwtAuthGuard, RolesGuard } from '@island.is/judicial-system/auth'
import { CaseController } from '../../case.controller'
@@ -14,55 +12,10 @@ describe('CaseController - Get by id guards', () => {
guards = Reflect.getMetadata('__guards__', CaseController.prototype.getById)
})
- it('should have four guards', () => {
- expect(guards).toHaveLength(4)
- })
-
- describe('JwtAuthGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[0]()
- })
-
- it('should have JwtAuthGuard as guard 1', () => {
- expect(guard).toBeInstanceOf(JwtAuthGuard)
- })
- })
-
- describe('RolesGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[1]()
- })
-
- it('should have RolesGuard as guard 2', () => {
- expect(guard).toBeInstanceOf(RolesGuard)
- })
- })
-
- describe('CaseExistsGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[2]()
- })
-
- it('should have CaseExistsGuard as guard 3', () => {
- expect(guard).toBeInstanceOf(CaseExistsGuard)
- })
- })
-
- describe('CaseReadGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[3]()
- })
-
- it('should have CaseReadGuard as guard 4', () => {
- expect(guard).toBeInstanceOf(CaseReadGuard)
- })
+ it('should have the right guard configuration', () => {
+ expect(new guards[0]()).toBeInstanceOf(JwtAuthGuard)
+ expect(new guards[1]()).toBeInstanceOf(RolesGuard)
+ expect(new guards[2]()).toBeInstanceOf(CaseExistsGuard)
+ expect(new guards[3]()).toBeInstanceOf(CaseReadGuard)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCaseFilesRecordPdfGuards.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCaseFilesRecordPdfGuards.spec.ts
index 3a9d1ed6a654..f59fec872cbf 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCaseFilesRecordPdfGuards.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCaseFilesRecordPdfGuards.spec.ts
@@ -1,5 +1,3 @@
-import { CanActivate } from '@nestjs/common'
-
import { JwtAuthGuard, RolesGuard } from '@island.is/judicial-system/auth'
import { indictmentCases } from '@island.is/judicial-system/types'
@@ -19,70 +17,15 @@ describe('CaseController - Get case files record pdf guards', () => {
)
})
- it('should have five guards', () => {
+ it('should have the right guard configuration', () => {
expect(guards).toHaveLength(5)
- })
-
- describe('JwtAuthGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[0]()
- })
-
- it('should have JwtAuthGuard as guard 1', () => {
- expect(guard).toBeInstanceOf(JwtAuthGuard)
- })
- })
-
- describe('RolesGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[1]()
- })
-
- it('should have RolesGuard as guard 2', () => {
- expect(guard).toBeInstanceOf(RolesGuard)
- })
- })
-
- describe('CaseExistsGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[2]()
- })
-
- it('should have CaseExistsGuard as guard 3', () => {
- expect(guard).toBeInstanceOf(CaseExistsGuard)
- })
- })
-
- describe('CaseTypeGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = guards[3]
- })
-
- it('should have CaseTypeGuard as guard 4', () => {
- expect(guard).toBeInstanceOf(CaseTypeGuard)
- expect(guard).toEqual({
- allowedCaseTypes: indictmentCases,
- })
- })
- })
-
- describe('CaseReadGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[4]()
- })
-
- it('should have CaseReadGuard as guard 5', () => {
- expect(guard).toBeInstanceOf(CaseReadGuard)
- })
+ expect(new guards[0]()).toBeInstanceOf(JwtAuthGuard)
+ expect(new guards[1]()).toBeInstanceOf(RolesGuard)
+ expect(new guards[2]()).toBeInstanceOf(CaseExistsGuard)
+ expect(guards[3]).toBeInstanceOf(CaseTypeGuard)
+ expect(guards[3]).toEqual({
+ allowedCaseTypes: indictmentCases,
+ })
+ expect(new guards[4]()).toBeInstanceOf(CaseReadGuard)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCourtRecordPdf.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCourtRecordPdf.spec.ts
index 2ce8eb9d2781..b2e92770f532 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCourtRecordPdf.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCourtRecordPdf.spec.ts
@@ -54,35 +54,15 @@ describe('CaseController - Get court record pdf', () => {
}
})
- describe('AWS S3 lookup', () => {
- const user = {} as User
- const caseId = uuid()
- const theCase = {
- id: caseId,
- courtRecordSignatureDate: nowFactory(),
- } as Case
- const res = {} as Response
-
- beforeEach(async () => {
- await givenWhenThen(caseId, user, theCase, res)
- })
-
- it('should lookup pdf', () => {
- expect(mockAwsS3Service.getObject).toHaveBeenCalledWith(
- `generated/${caseId}/courtRecord.pdf`,
- )
- })
- })
-
describe('AWS S3 pdf returned', () => {
- const user = {} as User
+ const user = { id: uuid() } as User
const caseId = uuid()
const theCase = {
id: caseId,
courtRecordSignatureDate: nowFactory(),
} as Case
const res = { end: jest.fn() } as unknown as Response
- const pdf = {}
+ const pdf = uuid()
beforeEach(async () => {
const mockGetObject = mockAwsS3Service.getObject as jest.Mock
@@ -91,24 +71,30 @@ describe('CaseController - Get court record pdf', () => {
await givenWhenThen(caseId, user, theCase, res)
})
- it('should return pdf', () => {
+ it('should lookup pdf', () => {
+ expect(mockAwsS3Service.getObject).toHaveBeenCalledWith(
+ `generated/${caseId}/courtRecord.pdf`,
+ )
expect(res.end).toHaveBeenCalledWith(pdf)
})
})
- describe('AWS S3 lookup fails', () => {
+ describe('generated pdf returned', () => {
const user = {} as User
const caseId = uuid()
const theCase = {
id: caseId,
courtRecordSignatureDate: nowFactory(),
} as Case
- const res = {} as Response
const error = new Error('Some ignored error')
+ const res = { end: jest.fn() } as unknown as Response
+ const pdf = uuid()
beforeEach(async () => {
const mockGetObject = mockAwsS3Service.getObject as jest.Mock
mockGetObject.mockRejectedValueOnce(error)
+ const getMock = getCourtRecordPdfAsBuffer as jest.Mock
+ getMock.mockResolvedValueOnce(pdf)
await givenWhenThen(caseId, user, theCase, res)
})
@@ -118,54 +104,11 @@ describe('CaseController - Get court record pdf', () => {
`The court record for case ${caseId} was not found in AWS S3`,
{ error },
)
- })
- })
-
- describe('pdf generated', () => {
- const user = {} as User
- const caseId = uuid()
- const theCase = {
- id: caseId,
- courtRecordSignatureDate: nowFactory(),
- } as Case
- const res = {} as Response
-
- beforeEach(async () => {
- const mockGetObject = mockAwsS3Service.getObject as jest.Mock
- mockGetObject.mockRejectedValueOnce(new Error('Some ignored error'))
-
- await givenWhenThen(caseId, user, theCase, res)
- })
-
- it('should generate pdf', () => {
expect(getCourtRecordPdfAsBuffer).toHaveBeenCalledWith(
theCase,
expect.any(Function),
user,
)
- })
- })
-
- describe('generated pdf returned', () => {
- const user = {} as User
- const caseId = uuid()
- const theCase = {
- id: caseId,
- courtRecordSignatureDate: nowFactory(),
- } as Case
- const res = { end: jest.fn() } as unknown as Response
- const pdf = {}
-
- beforeEach(async () => {
- const mockGetObject = mockAwsS3Service.getObject as jest.Mock
- mockGetObject.mockRejectedValueOnce(new Error('Some ignored error'))
- const getMock = getCourtRecordPdfAsBuffer as jest.Mock
- getMock.mockResolvedValueOnce(pdf)
-
- await givenWhenThen(caseId, user, theCase, res)
- })
-
- it('should return pdf', () => {
expect(res.end).toHaveBeenCalledWith(pdf)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCourtRecordPdfGuards.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCourtRecordPdfGuards.spec.ts
index dac78130793f..02eeb2286d1b 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCourtRecordPdfGuards.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCourtRecordPdfGuards.spec.ts
@@ -1,5 +1,3 @@
-import { CanActivate } from '@nestjs/common'
-
import { JwtAuthGuard, RolesGuard } from '@island.is/judicial-system/auth'
import {
investigationCases,
@@ -22,70 +20,15 @@ describe('CaseController - Get court record pdf guards', () => {
)
})
- it('should have five guards', () => {
+ it('should have the right guard configuration', () => {
expect(guards).toHaveLength(5)
- })
-
- describe('JwtAuthGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[0]()
- })
-
- it('should have JwtAuthGuard as guard 1', () => {
- expect(guard).toBeInstanceOf(JwtAuthGuard)
- })
- })
-
- describe('RolesGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[1]()
- })
-
- it('should have RolesGuard as guard 2', () => {
- expect(guard).toBeInstanceOf(RolesGuard)
- })
- })
-
- describe('CaseExistsGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[2]()
- })
-
- it('should have CaseExistsGuard as guard 3', () => {
- expect(guard).toBeInstanceOf(CaseExistsGuard)
- })
- })
-
- describe('CaseTypeGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = guards[3]
- })
-
- it('should have CaseTypeGuard as guard 4', () => {
- expect(guard).toBeInstanceOf(CaseTypeGuard)
- expect(guard).toEqual({
- allowedCaseTypes: [...restrictionCases, ...investigationCases],
- })
- })
- })
-
- describe('CaseReadGuard', () => {
- let guard: CanActivate
-
- beforeEach(() => {
- guard = new guards[4]()
- })
-
- it('should have CaseReadGuard as guard 5', () => {
- expect(guard).toBeInstanceOf(CaseReadGuard)
- })
+ expect(new guards[0]()).toBeInstanceOf(JwtAuthGuard)
+ expect(new guards[1]()).toBeInstanceOf(RolesGuard)
+ expect(new guards[2]()).toBeInstanceOf(CaseExistsGuard)
+ expect(guards[3]).toBeInstanceOf(CaseTypeGuard)
+ expect(guards[3]).toEqual({
+ allowedCaseTypes: [...restrictionCases, ...investigationCases],
+ })
+ expect(new guards[4]()).toBeInstanceOf(CaseReadGuard)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/transition.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/transition.spec.ts
index 825f2f5f3ef7..fc8d3c595769 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/transition.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/transition.spec.ts
@@ -415,6 +415,24 @@ describe('CaseController - Transition', () => {
},
],
)
+ } else if (
+ isIndictmentCase(theCase.type) &&
+ newState === CaseState.DRAFT &&
+ oldState === CaseState.RECEIVED
+ ) {
+ expect(mockMessageService.sendMessagesToQueue).toHaveBeenCalledWith(
+ [
+ {
+ type: MessageType.NOTIFICATION,
+ user: {
+ ...defaultUser,
+ canConfirmIndictment: isIndictmentCase(theCase.type),
+ },
+ caseId,
+ body: { type: NotificationType.INDICTMENT_RETURNED },
+ },
+ ],
+ )
} else {
expect(
mockMessageService.sendMessagesToQueue,
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/update.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/update.spec.ts
index caaa85375cd6..9dab74bdebeb 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/update.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/update.spec.ts
@@ -9,6 +9,7 @@ import {
CaseOrigin,
CaseState,
CaseType,
+ DateType,
indictmentCases,
InstitutionType,
investigationCases,
@@ -26,6 +27,7 @@ import { FileService } from '../../../file'
import { UserService } from '../../../user'
import { UpdateCaseDto } from '../../dto/updateCase.dto'
import { Case } from '../../models/case.model'
+import { DateLog } from '../../models/dateLog.model'
jest.mock('../../../../factories')
@@ -68,6 +70,7 @@ describe('CaseController - Update', () => {
let mockFileService: FileService
let transaction: Transaction
let mockCaseModel: typeof Case
+ let mockDateLogModel: typeof DateLog
let givenWhenThen: GivenWhenThen
beforeEach(async () => {
@@ -77,6 +80,7 @@ describe('CaseController - Update', () => {
fileService,
sequelize,
caseModel,
+ dateLogModel,
caseController,
} = await createTestingCaseModule()
@@ -84,6 +88,7 @@ describe('CaseController - Update', () => {
mockUserService = userService
mockFileService = fileService
mockCaseModel = caseModel
+ mockDateLogModel = dateLogModel
const mockTransaction = sequelize.transaction as jest.Mock
transaction = {} as Transaction
@@ -739,4 +744,138 @@ describe('CaseController - Update', () => {
])
})
})
+
+ describe('appeal case number updated with appeal files', () => {
+ const appealCaseNumber = uuid()
+ const caseToUpdate = { appealCaseNumber }
+ const caseFile1Id = uuid()
+ const caseFile2Id = uuid()
+ const caseFile3Id = uuid()
+ const caseFile4Id = uuid()
+ const caseFile5Id = uuid()
+ const caseFile6Id = uuid()
+ const caseFiles = [
+ caseFile,
+ {
+ id: caseFile1Id,
+ caseId,
+ category: CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT,
+ key: uuid(),
+ },
+ {
+ id: caseFile2Id,
+ caseId,
+ category: CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT_CASE_FILE,
+ key: uuid(),
+ },
+ {
+ id: caseFile3Id,
+ caseId,
+ category: CaseFileCategory.PROSECUTOR_APPEAL_CASE_FILE,
+ key: uuid(),
+ },
+ {
+ id: caseFile4Id,
+ caseId,
+ category: CaseFileCategory.DEFENDANT_APPEAL_STATEMENT,
+ key: uuid(),
+ },
+ {
+ id: caseFile5Id,
+ caseId,
+ category: CaseFileCategory.DEFENDANT_APPEAL_STATEMENT_CASE_FILE,
+ key: uuid(),
+ },
+ {
+ id: caseFile6Id,
+ caseId,
+ category: CaseFileCategory.DEFENDANT_APPEAL_CASE_FILE,
+ key: uuid(),
+ },
+ ]
+ const updatedCase = {
+ ...theCase,
+ type: CaseType.RESTRAINING_ORDER,
+ appealCaseNumber,
+ caseFiles,
+ }
+
+ beforeEach(async () => {
+ const mockFindOne = mockCaseModel.findOne as jest.Mock
+ mockFindOne.mockResolvedValueOnce(updatedCase)
+
+ await givenWhenThen(
+ caseId,
+ user,
+ {
+ ...theCase,
+ appealCaseNumber: uuid(),
+ caseFiles,
+ } as Case,
+ caseToUpdate,
+ )
+ })
+
+ it('should post to queue', () => {
+ expect(mockMessageService.sendMessagesToQueue).toHaveBeenCalledWith([
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId,
+ elementId: caseFile1Id,
+ },
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId,
+ elementId: caseFile2Id,
+ },
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId,
+ elementId: caseFile3Id,
+ },
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId,
+ elementId: caseFile4Id,
+ },
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId,
+ elementId: caseFile5Id,
+ },
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId,
+ elementId: caseFile6Id,
+ },
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_RECEIVED_DATE,
+ user,
+ caseId,
+ },
+ ])
+ })
+ })
+
+ describe('court date updated', () => {
+ const courtDate = new Date()
+ const caseToUpdate = { courtDate }
+
+ beforeEach(async () => {
+ await givenWhenThen(caseId, user, theCase, caseToUpdate)
+ })
+
+ it('should post to queue', () => {
+ expect(mockDateLogModel.create).toHaveBeenCalledWith(
+ { dateType: DateType.COURT_DATE, caseId, date: courtDate },
+ { transaction },
+ )
+ })
+ })
})
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/createTestingCaseModule.ts b/apps/judicial-system/backend/src/app/modules/case/test/createTestingCaseModule.ts
index 30c16fc9f6b7..16213c01603a 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/createTestingCaseModule.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/createTestingCaseModule.ts
@@ -31,6 +31,7 @@ import { LimitedAccessCaseController } from '../limitedAccessCase.controller'
import { LimitedAccessCaseService } from '../limitedAccessCase.service'
import { Case } from '../models/case.model'
import { CaseArchive } from '../models/caseArchive.model'
+import { DateLog } from '../models/dateLog.model'
import { PDFService } from '../pdf.service'
jest.mock('@island.is/judicial-system/message')
@@ -103,6 +104,12 @@ export const createTestingCaseModule = async () => {
provide: getModelToken(CaseArchive),
useValue: { create: jest.fn() },
},
+ {
+ provide: getModelToken(DateLog),
+ useValue: {
+ create: jest.fn(),
+ },
+ },
CaseService,
InternalCaseService,
LimitedAccessCaseService,
@@ -144,6 +151,8 @@ export const createTestingCaseModule = async () => {
getModelToken(CaseArchive),
)
+ const dateLogModel = caseModule.get(getModelToken(DateLog))
+
const caseConfig = caseModule.get>(
caseModuleConfig.KEY,
)
@@ -178,6 +187,7 @@ export const createTestingCaseModule = async () => {
sequelize,
caseModel,
caseArchiveModel,
+ dateLogModel,
caseConfig,
caseService,
limitedAccessCaseService,
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverAppealToPolice.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverAppealToPolice.spec.ts
index a135f931812d..e00a9c2cf05a 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverAppealToPolice.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverAppealToPolice.spec.ts
@@ -62,6 +62,7 @@ describe('InternalCaseController - Deliver appeal to police', () => {
const caseType = CaseType.CUSTODY
const caseState = CaseState.ACCEPTED
const policeCaseNumber = uuid()
+ const courtCaseNumber = uuid()
const defendantNationalId = '0123456789'
const validToDate = randomDate()
const caseConclusion = 'test conclusion'
@@ -74,6 +75,7 @@ describe('InternalCaseController - Deliver appeal to police', () => {
state: caseState,
appealState: CaseAppealState.COMPLETED,
policeCaseNumbers: [policeCaseNumber],
+ courtCaseNumber,
defendants: [{ nationalId: defendantNationalId }],
validToDate,
conclusion: caseConclusion,
@@ -101,6 +103,7 @@ describe('InternalCaseController - Deliver appeal to police', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
validToDate,
caseConclusion,
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseConclusionToCourt.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseConclusionToCourt.spec.ts
index b4a63fc375b0..3654edcf80ed 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseConclusionToCourt.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseConclusionToCourt.spec.ts
@@ -77,6 +77,7 @@ describe('InternalCaseController - Deliver case conclusion to court', () => {
caseId,
courtName,
courtCaseNumber,
+ false,
decision,
rulingDate,
validToDate,
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseFilesRecordToPolice.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseFilesRecordToPolice.spec.ts
index 7d783a625ad5..57d1eb6f335b 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseFilesRecordToPolice.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseFilesRecordToPolice.spec.ts
@@ -131,6 +131,7 @@ describe('InternalCaseController - Deliver case files record to police', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
date,
'',
@@ -160,6 +161,7 @@ describe('InternalCaseController - Deliver case files record to police', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
date,
'',
@@ -189,6 +191,7 @@ describe('InternalCaseController - Deliver case files record to police', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
date,
'',
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseToPolice.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseToPolice.spec.ts
index 5d7e79e6c020..d26aa17ec415 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseToPolice.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverCaseToPolice.spec.ts
@@ -70,6 +70,7 @@ describe('InternalCaseController - Deliver case to police', () => {
const caseType = CaseType.CUSTODY
const caseState = CaseState.ACCEPTED
const policeCaseNumber = uuid()
+ const courtCaseNumber = uuid()
const defendantNationalId = '0123456789'
const validToDate = randomDate()
const caseConclusion = 'test conclusion'
@@ -79,6 +80,7 @@ describe('InternalCaseController - Deliver case to police', () => {
type: caseType,
state: caseState,
policeCaseNumbers: [policeCaseNumber],
+ courtCaseNumber,
defendants: [{ nationalId: defendantNationalId }],
validToDate,
conclusion: caseConclusion,
@@ -122,6 +124,7 @@ describe('InternalCaseController - Deliver case to police', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
validToDate,
caseConclusion,
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverIndictmentCaseToPolice.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverIndictmentCaseToPolice.spec.ts
index 07a864a2225b..8bc07564f842 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverIndictmentCaseToPolice.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverIndictmentCaseToPolice.spec.ts
@@ -67,6 +67,7 @@ describe('InternalCaseController - Deliver indictment case to police', () => {
const caseType = CaseType.INDICTMENT
const caseState = CaseState.ACCEPTED
const policeCaseNumber = uuid()
+ const courtCaseNumber = uuid()
const defendantNationalId = '0123456789'
const courtRecordKey = uuid()
const courtRecordPdf = 'test court record'
@@ -78,6 +79,7 @@ describe('InternalCaseController - Deliver indictment case to police', () => {
type: caseType,
state: caseState,
policeCaseNumbers: [policeCaseNumber],
+ courtCaseNumber,
defendants: [{ nationalId: defendantNationalId }],
caseFiles: [
{ key: courtRecordKey, category: CaseFileCategory.COURT_RECORD },
@@ -107,6 +109,7 @@ describe('InternalCaseController - Deliver indictment case to police', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
date,
'',
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverIndictmentToPolice.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverIndictmentToPolice.spec.ts
index e817074396ca..6eab6494afed 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverIndictmentToPolice.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverIndictmentToPolice.spec.ts
@@ -73,6 +73,7 @@ describe('InternalCaseController - Deliver indictment to police', () => {
const caseType = CaseType.INDICTMENT
const caseState = CaseState.ACCEPTED
const policeCaseNumber = uuid()
+ const courtCaseNumber = uuid()
const defendantNationalId = '0123456789'
const indictmentKey = uuid()
const indictmentPdf = 'test indictment'
@@ -82,6 +83,7 @@ describe('InternalCaseController - Deliver indictment to police', () => {
type: caseType,
state: caseState,
policeCaseNumbers: [policeCaseNumber],
+ courtCaseNumber,
defendants: [{ nationalId: defendantNationalId }],
caseFiles: [
{ key: indictmentKey, category: CaseFileCategory.INDICTMENT },
@@ -108,6 +110,7 @@ describe('InternalCaseController - Deliver indictment to police', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
date,
'',
@@ -127,6 +130,7 @@ describe('InternalCaseController - Deliver indictment to police', () => {
const caseType = CaseType.INDICTMENT
const caseState = CaseState.ACCEPTED
const policeCaseNumber = uuid()
+ const courtCaseNumber = uuid()
const defendantNationalId = '0123456789'
const indictmentPdf = 'test indictment'
const theCase = {
@@ -135,6 +139,7 @@ describe('InternalCaseController - Deliver indictment to police', () => {
type: caseType,
state: caseState,
policeCaseNumbers: [policeCaseNumber],
+ courtCaseNumber,
defendants: [{ nationalId: defendantNationalId }],
} as Case
@@ -162,6 +167,7 @@ describe('InternalCaseController - Deliver indictment to police', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
date,
'',
diff --git a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverSignedRulingToPolice.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverSignedRulingToPolice.spec.ts
index f84432e38ae8..bfc2c4634e90 100644
--- a/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverSignedRulingToPolice.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/case/test/internalCaseController/deliverSignedRulingToPolice.spec.ts
@@ -60,6 +60,7 @@ describe('InternalCaseController - Deliver signed ruling to police', () => {
const caseType = CaseType.CUSTODY
const caseState = CaseState.ACCEPTED
const policeCaseNumber = uuid()
+ const courtCaseNumber = uuid()
const defendantNationalId = '0123456789'
const validToDate = randomDate()
const caseConclusion = 'test conclusion'
@@ -69,6 +70,7 @@ describe('InternalCaseController - Deliver signed ruling to police', () => {
type: caseType,
state: caseState,
policeCaseNumbers: [policeCaseNumber],
+ courtCaseNumber,
defendants: [{ nationalId: defendantNationalId }],
validToDate,
conclusion: caseConclusion,
@@ -97,6 +99,7 @@ describe('InternalCaseController - Deliver signed ruling to police', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
validToDate,
caseConclusion,
diff --git a/apps/judicial-system/backend/src/app/modules/court/court.module.ts b/apps/judicial-system/backend/src/app/modules/court/court.module.ts
index 89a5c6813141..3d951420ea6d 100644
--- a/apps/judicial-system/backend/src/app/modules/court/court.module.ts
+++ b/apps/judicial-system/backend/src/app/modules/court/court.module.ts
@@ -1,4 +1,5 @@
import { forwardRef, Module } from '@nestjs/common'
+import { SequelizeModule } from '@nestjs/sequelize'
import { EmailModule } from '@island.is/email-service'
@@ -6,10 +7,12 @@ import { CourtClientModule } from '@island.is/judicial-system/court-client'
import { environment } from '../../../environments'
import { EventModule } from '../index'
+import { RobotLog } from './models/robotLog.model'
import { CourtService } from './court.service'
@Module({
imports: [
+ SequelizeModule.forFeature([RobotLog]),
CourtClientModule,
EmailModule.register(environment.emailOptions),
forwardRef(() => EventModule),
diff --git a/apps/judicial-system/backend/src/app/modules/court/court.service.ts b/apps/judicial-system/backend/src/app/modules/court/court.service.ts
index e79567937323..63fa67d1d389 100644
--- a/apps/judicial-system/backend/src/app/modules/court/court.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/court/court.service.ts
@@ -1,10 +1,9 @@
import formatISO from 'date-fns/formatISO'
-import { QueryTypes } from 'sequelize'
import { Sequelize } from 'sequelize-typescript'
import { ConfidentialClientApplication } from '@azure/msal-node'
import { Inject, Injectable, ServiceUnavailableException } from '@nestjs/common'
-import { InjectConnection } from '@nestjs/sequelize'
+import { InjectConnection, InjectModel } from '@nestjs/sequelize'
import { EmailService } from '@island.is/email-service'
import type { Logger } from '@island.is/logging'
@@ -17,6 +16,7 @@ import type { User } from '@island.is/judicial-system/types'
import {
CaseAppealRulingDecision,
CaseDecision,
+ CaseFileCategory,
CaseType,
IndictmentSubtype,
IndictmentSubtypeMap,
@@ -25,6 +25,7 @@ import {
import { nowFactory } from '../../factories'
import { EventService } from '../event'
+import { RobotLog } from './models/robotLog.model'
import { courtModuleConfig } from './court.config'
export enum CourtDocumentFolder {
@@ -49,6 +50,8 @@ export const courtSubtypes: CourtSubtypes = {
LEGAL_ENFORCEMENT_LAWS: 'Brot gegn lögreglulögum',
POLICE_REGULATIONS: 'Brot gegn lögreglusamþykkt',
INTIMATE_RELATIONS: 'Brot í nánu sambandi',
+ ANIMAL_PROTECTION: 'Brot á lögum um dýravernd',
+ FOREIGN_NATIONALS: 'Brot á lögum um útlendinga',
PUBLIC_SERVICE_VIOLATION: 'Brot í opinberu starfi',
PROPERTY_DAMAGE: 'Eignaspjöll',
NARCOTICS_OFFENSE: 'Fíkniefnalagabrot',
@@ -65,6 +68,8 @@ export const courtSubtypes: CourtSubtypes = {
MINOR_ASSAULT: 'Líkamsárás - minniháttar',
AGGRAVATED_ASSAULT: 'Líkamsárás - sérlega hættuleg',
ASSAULT_LEADING_TO_DEATH: 'Líkamsárás sem leiðir til dauða',
+ BODILY_INJURY: 'Líkamsmeiðingar',
+ MEDICINES_OFFENSE: 'Lyfjalög',
MURDER: 'Manndráp',
RAPE: 'Nauðgun',
UTILITY_THEFT: 'Nytjastuldur',
@@ -115,6 +120,14 @@ export const courtSubtypes: CourtSubtypes = {
VIDEO_RECORDING_EQUIPMENT: 'Annað',
}
+enum RobotEmailType {
+ CASE_CONCLUSION = 'CASE_CONCLUSION',
+ APPEAL_CASE_RECEIVED_DATE = 'APPEAL_CASE_RECEIVED_DATE',
+ APPEAL_CASE_ASSIGNED_ROLES = 'APPEAL_CASE_ASSIGNED_ROLES',
+ APPEAL_CASE_CONCLUSION = 'APPEAL_CASE_CONCLUSION',
+ APPEAL_CASE_FILE = 'APPEAL_CASE_FILE',
+}
+
@Injectable()
export class CourtService {
private confidentintialClientApplication?: ConfidentialClientApplication
@@ -124,6 +137,7 @@ export class CourtService {
private readonly emailService: EmailService,
private readonly eventService: EventService,
@InjectConnection() private readonly sequelize: Sequelize,
+ @InjectModel(RobotLog) private readonly robotLogModel: typeof RobotLog,
@Inject(LOGGER_PROVIDER) private readonly logger: Logger,
@Inject(courtModuleConfig.KEY)
private readonly config: ConfigType,
@@ -495,6 +509,7 @@ export class CourtService {
caseId: string,
courtName?: string,
courtCaseNumber?: string,
+ isCorrection?: boolean,
decision?: CaseDecision,
rulingDate?: Date,
validToDate?: Date,
@@ -503,6 +518,7 @@ export class CourtService {
try {
const subject = `${courtName} - ${courtCaseNumber} - lyktir`
const content = JSON.stringify({
+ isCorrection,
courtName,
courtCaseNumber,
decision,
@@ -511,7 +527,12 @@ export class CourtService {
isolationToDate,
})
- return this.sendToRobot(subject, content)
+ return this.sendToRobot(
+ subject,
+ content,
+ RobotEmailType.CASE_CONCLUSION,
+ caseId,
+ )
} catch (error) {
this.eventService.postErrorEvent(
'Failed to update court case with conclusion',
@@ -519,6 +540,7 @@ export class CourtService {
caseId,
actor: user.name,
institution: user.institution?.name,
+ isCorrection,
courtName,
courtCaseNumber,
decision,
@@ -543,7 +565,12 @@ export class CourtService {
const subject = `Landsréttur - ${appealCaseNumber} - móttaka`
const content = JSON.stringify({ appealReceivedByCourtDate })
- return this.sendToRobot(subject, content)
+ return this.sendToRobot(
+ subject,
+ content,
+ RobotEmailType.APPEAL_CASE_RECEIVED_DATE,
+ caseId,
+ )
} catch (error) {
this.eventService.postErrorEvent(
'Failed to update appeal case with received date',
@@ -579,7 +606,12 @@ export class CourtService {
appealJudge3NationalId,
})
- return this.sendToRobot(subject, content)
+ return this.sendToRobot(
+ subject,
+ content,
+ RobotEmailType.APPEAL_CASE_ASSIGNED_ROLES,
+ caseId,
+ )
} catch (error) {
this.eventService.postErrorEvent(
'Failed to update appeal case with assigned roles',
@@ -616,7 +648,12 @@ export class CourtService {
appealRulingDate,
})
- return this.sendToRobot(subject, content)
+ return this.sendToRobot(
+ subject,
+ content,
+ RobotEmailType.APPEAL_CASE_CONCLUSION,
+ caseId,
+ )
} catch (error) {
this.eventService.postErrorEvent(
'Failed to update appeal case with conclusion',
@@ -636,17 +673,65 @@ export class CourtService {
}
}
- private async getNextRobotEmailNumber() {
- return this.sequelize
- .query<{ nextval: number }>(`SELECT nextval('robot_email_seq')`, {
- type: QueryTypes.SELECT,
- plain: true,
- })
- .then((result) => result?.nextval ?? 0)
+ async updateAppealCaseWithFile(
+ user: User,
+ caseId: string,
+ fileId: string,
+ appealCaseNumber?: string,
+ category?: CaseFileCategory,
+ name?: string,
+ url?: string,
+ dateSent?: Date,
+ ): Promise {
+ try {
+ const subject = `Landsréttur - ${appealCaseNumber} - skjal`
+ const content = JSON.stringify({ category, name, url, dateSent })
+
+ return this.sendToRobot(
+ subject,
+ content,
+ RobotEmailType.APPEAL_CASE_FILE,
+ caseId,
+ fileId,
+ )
+ } catch (error) {
+ this.eventService.postErrorEvent(
+ 'Failed to update appeal case with file',
+ {
+ caseId,
+ actor: user.name,
+ institution: user.institution?.name,
+ appealCaseNumber,
+ category,
+ name,
+ url,
+ dateSent,
+ },
+ error,
+ )
+
+ throw error
+ }
+ }
+
+ private async createRobotLog(
+ type: RobotEmailType,
+ caseId: string,
+ elementId?: string,
+ ) {
+ return this.robotLogModel
+ .create({ type, caseId, elementId })
+ .then((log) => [log.id, log.seqNumber])
}
- private async sendToRobot(subject: string, content: string) {
- const nextval = await this.getNextRobotEmailNumber() // Default to 0 if no result
+ private async sendToRobot(
+ subject: string,
+ content: string,
+ type: RobotEmailType,
+ caseId: string,
+ elementId?: string,
+ ) {
+ const [logId, nextval] = await this.createRobotLog(type, caseId, elementId)
const subjectWithNumber = `${subject} - ${nextval}`
if (this.config.useMicrosoftGraphApiForCourtRobot) {
@@ -693,25 +778,32 @@ export class CourtService {
},
)
})
+ .then(() =>
+ this.robotLogModel.update({ sent: true }, { where: { id: logId } }),
+ )
}
- return this.emailService.sendEmail({
- from: {
- name: this.config.fromName,
- address: this.config.fromEmail,
- },
- replyTo: {
- name: this.config.replyToName,
- address: this.config.replyToEmail,
- },
- to: [
- {
- name: this.config.courtRobotName,
- address: this.config.courtRobotEmail,
+ return this.emailService
+ .sendEmail({
+ from: {
+ name: this.config.fromName,
+ address: this.config.fromEmail,
+ },
+ replyTo: {
+ name: this.config.replyToName,
+ address: this.config.replyToEmail,
},
- ],
- subject: subjectWithNumber,
- text: content,
- })
+ to: [
+ {
+ name: this.config.courtRobotName,
+ address: this.config.courtRobotEmail,
+ },
+ ],
+ subject: subjectWithNumber,
+ text: content,
+ })
+ .then(() =>
+ this.robotLogModel.update({ sent: true }, { where: { id: logId } }),
+ )
}
}
diff --git a/apps/judicial-system/backend/src/app/modules/court/models/robotLog.model.ts b/apps/judicial-system/backend/src/app/modules/court/models/robotLog.model.ts
new file mode 100644
index 000000000000..90a87da5ef63
--- /dev/null
+++ b/apps/judicial-system/backend/src/app/modules/court/models/robotLog.model.ts
@@ -0,0 +1,45 @@
+import {
+ Column,
+ CreatedAt,
+ DataType,
+ ForeignKey,
+ Model,
+ Table,
+} from 'sequelize-typescript'
+
+import { Case } from '../../case/models/case.model'
+
+@Table({
+ tableName: 'robot_log',
+ timestamps: false,
+})
+export class RobotLog extends Model {
+ @Column({
+ type: DataType.UUID,
+ primaryKey: true,
+ defaultValue: DataType.UUIDV4,
+ })
+ id!: string
+
+ @CreatedAt
+ created!: Date
+
+ @Column({ type: DataType.INTEGER })
+ seqNumber!: number
+
+ @Column({ type: DataType.BOOLEAN })
+ delivered!: boolean
+
+ @Column({ type: DataType.STRING })
+ type!: string
+
+ @ForeignKey(() => Case)
+ @Column({ type: DataType.UUID })
+ caseId!: string
+
+ @Column({
+ type: DataType.STRING,
+ allowNull: true,
+ })
+ elementId?: string
+}
diff --git a/apps/judicial-system/backend/src/app/modules/court/test/createTestingCourtModule.ts b/apps/judicial-system/backend/src/app/modules/court/test/createTestingCourtModule.ts
index def4b53bfe73..d5f4e9974c60 100644
--- a/apps/judicial-system/backend/src/app/modules/court/test/createTestingCourtModule.ts
+++ b/apps/judicial-system/backend/src/app/modules/court/test/createTestingCourtModule.ts
@@ -1,5 +1,6 @@
import { mock } from 'jest-mock-extended'
+import { getModelToken } from '@nestjs/sequelize'
import { Test } from '@nestjs/testing'
import { LOGGER_PROVIDER } from '@island.is/logging'
@@ -9,6 +10,7 @@ import { CourtClientService } from '@island.is/judicial-system/court-client'
import { courtModuleConfig } from '../court.config'
import { CourtService } from '../court.service'
+import { RobotLog } from '../models/robotLog.model'
export const createTestingCourtModule = async () => {
const courtModule = await Test.createTestingModule({
@@ -26,6 +28,13 @@ export const createTestingCourtModule = async () => {
error: jest.fn(),
},
},
+ {
+ provide: getModelToken(RobotLog),
+ useValue: {
+ create: jest.fn(),
+ update: jest.fn(),
+ },
+ },
CourtService,
],
})
diff --git a/apps/judicial-system/backend/src/app/modules/event-log/eventLog.service.ts b/apps/judicial-system/backend/src/app/modules/event-log/eventLog.service.ts
index e8b14cb4d168..044af37f6c89 100644
--- a/apps/judicial-system/backend/src/app/modules/event-log/eventLog.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/event-log/eventLog.service.ts
@@ -52,6 +52,7 @@ export class EventLogService {
userRole,
})
} catch (error) {
+ // Tolerate failure but log error
this.logger.error('Failed to create event log', error)
}
}
diff --git a/apps/judicial-system/backend/src/app/modules/event-log/models/eventLog.model.ts b/apps/judicial-system/backend/src/app/modules/event-log/models/eventLog.model.ts
index 6522f4a10e92..9f6147745b52 100644
--- a/apps/judicial-system/backend/src/app/modules/event-log/models/eventLog.model.ts
+++ b/apps/judicial-system/backend/src/app/modules/event-log/models/eventLog.model.ts
@@ -9,6 +9,8 @@ import {
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
+import { EventType } from '@island.is/judicial-system/types'
+
import { Case } from '../../case/models/case.model'
@Table({
@@ -34,7 +36,7 @@ export class EventLog extends Model {
@Column({ type: DataType.STRING })
@ApiProperty()
- eventType!: string
+ eventType!: EventType
@ForeignKey(() => Case)
@Column({ type: DataType.UUID, allowNull: true })
diff --git a/apps/judicial-system/backend/src/app/modules/event/event.service.ts b/apps/judicial-system/backend/src/app/modules/event/event.service.ts
index 4b8e7201bdf7..530c8e40818f 100644
--- a/apps/judicial-system/backend/src/app/modules/event/event.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/event/event.service.ts
@@ -12,7 +12,11 @@ import {
formatDate,
readableIndictmentSubtypes,
} from '@island.is/judicial-system/formatters'
-import { isIndictmentCase } from '@island.is/judicial-system/types'
+import {
+ DateType,
+ getLatestDateType,
+ isIndictmentCase,
+} from '@island.is/judicial-system/types'
import { Case } from '../case'
import { eventModuleConfig } from './event.config'
@@ -94,6 +98,8 @@ export class EventService {
return
}
+ const courtDate = getLatestDateType(DateType.COURT_DATE, theCase.dateLogs)
+
const title =
event === CaseEvent.ACCEPT && isIndictmentCase(theCase.type)
? caseEvent[CaseEvent.ACCEPT_INDICTMENT]
@@ -124,7 +130,7 @@ export class EventService {
}\n>Dómritari ${
theCase.registrar?.name ?? 'er ekki skráður'
}\n>Fyrirtaka ${
- formatDate(theCase.courtDate, 'Pp') ?? 'er ekki skráð'
+ formatDate(courtDate?.date, 'Pp') ?? 'er ekki skráð'
}`
: ''
diff --git a/apps/judicial-system/backend/src/app/modules/file/file.config.ts b/apps/judicial-system/backend/src/app/modules/file/file.config.ts
new file mode 100644
index 000000000000..a6e39fa8b3ad
--- /dev/null
+++ b/apps/judicial-system/backend/src/app/modules/file/file.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from '@island.is/nest/config'
+
+export const fileModuleConfig = defineConfig({
+ name: 'FileModule',
+ load: (env) => ({
+ robotS3TimeToLiveGet: +(
+ (env.optional('ROBOT_S3_TIME_TO_LIVE_GET') ?? '86400') // 24 hours, convert to number with +
+ ),
+ }),
+})
diff --git a/apps/judicial-system/backend/src/app/modules/file/file.controller.ts b/apps/judicial-system/backend/src/app/modules/file/file.controller.ts
index a596401998ae..c44a97392ed2 100644
--- a/apps/judicial-system/backend/src/app/modules/file/file.controller.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/file.controller.ts
@@ -114,12 +114,13 @@ export class FileController {
})
async createCaseFile(
@Param('caseId') caseId: string,
+ @CurrentHttpUser() user: User,
@CurrentCase() theCase: Case,
@Body() createFile: CreateFileDto,
): Promise {
this.logger.debug(`Creating a file for case ${caseId}`)
- return this.fileService.createCaseFile(theCase, createFile)
+ return this.fileService.createCaseFile(theCase, createFile, user)
}
@UseGuards(
diff --git a/apps/judicial-system/backend/src/app/modules/file/file.module.ts b/apps/judicial-system/backend/src/app/modules/file/file.module.ts
index f711f1174b8e..b76ea0352f28 100644
--- a/apps/judicial-system/backend/src/app/modules/file/file.module.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/file.module.ts
@@ -3,6 +3,8 @@ import { SequelizeModule } from '@nestjs/sequelize'
import { CmsTranslationsModule } from '@island.is/cms-translations'
+import { MessageModule } from '@island.is/judicial-system/message'
+
import { AwsS3Module, CaseModule, CourtModule } from '../index'
import { CaseFile } from './models/file.model'
import { FileController } from './file.controller'
@@ -13,6 +15,7 @@ import { LimitedAccessFileController } from './limitedAccessFile.controller'
@Module({
imports: [
CmsTranslationsModule,
+ MessageModule,
forwardRef(() => CaseModule),
forwardRef(() => CourtModule),
forwardRef(() => AwsS3Module),
diff --git a/apps/judicial-system/backend/src/app/modules/file/file.service.ts b/apps/judicial-system/backend/src/app/modules/file/file.service.ts
index 84bc23a3a369..89f131372162 100644
--- a/apps/judicial-system/backend/src/app/modules/file/file.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/file.service.ts
@@ -13,7 +13,9 @@ import { InjectConnection, InjectModel } from '@nestjs/sequelize'
import type { Logger } from '@island.is/logging'
import { LOGGER_PROVIDER } from '@island.is/logging'
+import type { ConfigType } from '@island.is/nest/config'
+import { MessageService, MessageType } from '@island.is/judicial-system/message'
import type { User } from '@island.is/judicial-system/types'
import {
CaseFileCategory,
@@ -31,10 +33,12 @@ import { CreateFileDto } from './dto/createFile.dto'
import { CreatePresignedPostDto } from './dto/createPresignedPost.dto'
import { UpdateFileDto } from './dto/updateFile.dto'
import { DeleteFileResponse } from './models/deleteFile.response'
+import { DeliverResponse } from './models/deliver.response'
import { CaseFile } from './models/file.model'
import { PresignedPost } from './models/presignedPost.model'
import { SignedUrl } from './models/signedUrl.model'
import { UploadFileToCourtResponse } from './models/uploadFileToCourt.response'
+import { fileModuleConfig } from './file.config'
// Files are stored in AWS S3 under a key which has the following formats:
// uploads/// for restriction and investigation cases
@@ -53,6 +57,9 @@ export class FileService {
@InjectModel(CaseFile) private readonly fileModel: typeof CaseFile,
private readonly courtService: CourtService,
private readonly awsS3Service: AwsS3Service,
+ private readonly messageService: MessageService,
+ @Inject(fileModuleConfig.KEY)
+ private readonly config: ConfigType,
@Inject(LOGGER_PROVIDER) private readonly logger: Logger,
) {}
@@ -195,6 +202,7 @@ export class FileService {
async createCaseFile(
theCase: Case,
createFile: CreateFileDto,
+ user: User,
): Promise {
const { key } = createFile
@@ -216,13 +224,35 @@ export class FileService {
: NAME_BEGINS_INDEX,
)
- return this.fileModel.create({
+ const file = await this.fileModel.create({
...createFile,
state: CaseFileState.STORED_IN_RVG,
caseId: theCase.id,
name: fileName,
userGeneratedFilename: fileName.replace(/\.pdf$/, ''),
})
+
+ if (
+ file.category &&
+ [
+ CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT,
+ CaseFileCategory.DEFENDANT_APPEAL_STATEMENT,
+ CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT_CASE_FILE,
+ CaseFileCategory.DEFENDANT_APPEAL_STATEMENT_CASE_FILE,
+ CaseFileCategory.PROSECUTOR_APPEAL_CASE_FILE,
+ CaseFileCategory.DEFENDANT_APPEAL_CASE_FILE,
+ ].includes(file.category)
+ ) {
+ await this.messageService.sendMessagesToQueue([
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId: theCase.id,
+ elementId: file.id,
+ },
+ ])
+ }
+ return file
}
async getCaseFileSignedUrl(
@@ -230,7 +260,7 @@ export class FileService {
file: CaseFile,
): Promise {
if (!file.key) {
- throw new NotFoundException(`File ${file.id} does not exists in AWS S3`)
+ throw new NotFoundException(`File ${file.id} does not exist in AWS S3`)
}
let key = file.key
@@ -252,10 +282,10 @@ export class FileService {
// Fire and forget, no need to wait for the result
this.fileModel.update({ key: null }, { where: { id: file.id } })
- throw new NotFoundException(`File ${file.id} does not exists in AWS S3`)
+ throw new NotFoundException(`File ${file.id} does not exist in AWS S3`)
}
- return this.awsS3Service.getSignedUrl(key)
+ return this.awsS3Service.getSignedUrl(key).then((url) => ({ url }))
}
async deleteCaseFile(
@@ -282,7 +312,7 @@ export class FileService {
}
if (!file.key) {
- throw new NotFoundException(`File ${file.id} does not exists in AWS S3`)
+ throw new NotFoundException(`File ${file.id} does not exist in AWS S3`)
}
const exists = await this.awsS3Service.objectExists(file.key)
@@ -291,7 +321,7 @@ export class FileService {
// Fire and forget, no need to wait for the result
this.fileModel.update({ key: null }, { where: { id: file.id } })
- throw new NotFoundException(`File ${file.id} does not exists in AWS S3`)
+ throw new NotFoundException(`File ${file.id} does not exist in AWS S3`)
}
this.throttle = this.throttleUpload(file, theCase, user)
@@ -428,4 +458,49 @@ export class FileService {
{ where: { caseId, state: CaseFileState.STORED_IN_COURT }, transaction },
)
}
+
+ async deliverCaseFileToCourtOfAppeals(
+ file: CaseFile,
+ theCase: Case,
+ user: User,
+ ): Promise {
+ if (!file.key) {
+ throw new NotFoundException(`File ${file.id} does not exist in AWS S3`)
+ }
+
+ const exists = await this.awsS3Service.objectExists(file.key)
+
+ if (!exists) {
+ // Fire and forget, no need to wait for the result
+ this.fileModel.update({ key: null }, { where: { id: file.id } })
+
+ throw new NotFoundException(`File ${file.id} does not exist in AWS S3`)
+ }
+
+ const url = await this.awsS3Service.getSignedUrl(
+ file.key ?? '',
+ this.config.robotS3TimeToLiveGet,
+ )
+
+ return this.courtService
+ .updateAppealCaseWithFile(
+ user,
+ theCase.id,
+ file.id,
+ theCase.appealCaseNumber,
+ file.category,
+ file.name,
+ url,
+ file.created,
+ )
+ .then(() => ({ delivered: true }))
+ .catch((reason) => {
+ this.logger.error(
+ `Failed to update appeal case ${theCase.id} with file`,
+ { reason },
+ )
+
+ return { delivered: false }
+ })
+ }
}
diff --git a/apps/judicial-system/backend/src/app/modules/file/internalFile.controller.ts b/apps/judicial-system/backend/src/app/modules/file/internalFile.controller.ts
index 4a9d45fa84ea..c677eb2fc68c 100644
--- a/apps/judicial-system/backend/src/app/modules/file/internalFile.controller.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/internalFile.controller.ts
@@ -87,4 +87,32 @@ export class InternalFileController {
return { delivered: success }
}
+
+ @UseGuards(CaseExistsGuard, CaseFileExistsGuard)
+ @Post(
+ `${
+ messageEndpoint[MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE]
+ }/:fileId`,
+ )
+ @ApiCreatedResponse({
+ type: DeliverResponse,
+ description: 'Delivers a case file to court of appeals',
+ })
+ deliverCaseFileToCourtOfAppeals(
+ @Param('caseId') caseId: string,
+ @Param('fileId') fileId: string,
+ @CurrentCase() theCase: Case,
+ @CurrentCaseFile() caseFile: CaseFile,
+ @Body() deliverDto: DeliverDto,
+ ): Promise {
+ this.logger.debug(
+ `Delivering file ${fileId} of case ${caseId} to court of appeals`,
+ )
+
+ return this.fileService.deliverCaseFileToCourtOfAppeals(
+ caseFile,
+ theCase,
+ deliverDto.user,
+ )
+ }
}
diff --git a/apps/judicial-system/backend/src/app/modules/file/limitedAccessFile.controller.ts b/apps/judicial-system/backend/src/app/modules/file/limitedAccessFile.controller.ts
index 00bbbf9c7b66..c729ee60ef19 100644
--- a/apps/judicial-system/backend/src/app/modules/file/limitedAccessFile.controller.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/limitedAccessFile.controller.ts
@@ -14,10 +14,12 @@ import type { Logger } from '@island.is/logging'
import { LOGGER_PROVIDER } from '@island.is/logging'
import {
+ CurrentHttpUser,
JwtAuthGuard,
RolesGuard,
RolesRules,
} from '@island.is/judicial-system/auth'
+import type { User } from '@island.is/judicial-system/types'
import {
investigationCases,
restrictionCases,
@@ -92,12 +94,13 @@ export class LimitedAccessFileController {
})
async createCaseFile(
@Param('caseId') caseId: string,
+ @CurrentHttpUser() user: User,
@CurrentCase() theCase: Case,
@Body() createFile: CreateFileDto,
): Promise {
this.logger.debug(`Creating a file for case ${caseId}`)
- return this.fileService.createCaseFile(theCase, createFile)
+ return this.fileService.createCaseFile(theCase, createFile, user)
}
@UseGuards(CaseReadGuard, CaseFileExistsGuard, LimitedAccessViewCaseFileGuard)
diff --git a/apps/judicial-system/backend/src/app/modules/file/test/createTestingFileModule.ts b/apps/judicial-system/backend/src/app/modules/file/test/createTestingFileModule.ts
index 9354afb57e05..5787422f69d7 100644
--- a/apps/judicial-system/backend/src/app/modules/file/test/createTestingFileModule.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/test/createTestingFileModule.ts
@@ -7,19 +7,23 @@ import { Test } from '@nestjs/testing'
import { IntlService } from '@island.is/cms-translations'
import { createTestIntl } from '@island.is/cms-translations/test'
import { LOGGER_PROVIDER } from '@island.is/logging'
+import { ConfigModule, ConfigType } from '@island.is/nest/config'
import { SharedAuthModule } from '@island.is/judicial-system/auth'
+import { MessageService } from '@island.is/judicial-system/message'
import { environment } from '../../../../environments'
import { AwsS3Service } from '../../aws-s3'
import { CaseService } from '../../case'
import { CourtService } from '../../court'
+import { fileModuleConfig } from '../file.config'
import { FileController } from '../file.controller'
import { FileService } from '../file.service'
import { InternalFileController } from '../internalFile.controller'
import { LimitedAccessFileController } from '../limitedAccessFile.controller'
import { CaseFile } from '../models/file.model'
+jest.mock('@island.is/judicial-system/message')
jest.mock('../../aws-s3/awsS3.service.ts')
jest.mock('../../court/court.service.ts')
jest.mock('../../case/case.service.ts')
@@ -31,6 +35,7 @@ export const createTestingFileModule = async () => {
jwtSecret: environment.auth.jwtSecret,
secretToken: environment.auth.secretToken,
}),
+ ConfigModule.forRoot({ load: [fileModuleConfig] }),
],
controllers: [
FileController,
@@ -38,6 +43,7 @@ export const createTestingFileModule = async () => {
LimitedAccessFileController,
],
providers: [
+ MessageService,
CaseService,
CourtService,
AwsS3Service,
@@ -80,6 +86,8 @@ export const createTestingFileModule = async () => {
})
.compile()
+ const messageService = fileModule.get(MessageService)
+
const awsS3Service = fileModule.get(AwsS3Service)
const courtService = fileModule.get(CourtService)
@@ -88,6 +96,10 @@ export const createTestingFileModule = async () => {
getModelToken(CaseFile),
)
+ const fileConfig = fileModule.get>(
+ fileModuleConfig.KEY,
+ )
+
const fileService = fileModule.get(FileService)
const fileController = fileModule.get(FileController)
@@ -105,9 +117,11 @@ export const createTestingFileModule = async () => {
return {
sequelize,
+ messageService,
awsS3Service,
courtService,
fileModel,
+ fileConfig,
fileService,
fileController,
internalFileController,
diff --git a/apps/judicial-system/backend/src/app/modules/file/test/fileController/createCaseFile.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/fileController/createCaseFile.spec.ts
index 4ab7394060a2..f03f905ba416 100644
--- a/apps/judicial-system/backend/src/app/modules/file/test/fileController/createCaseFile.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/test/fileController/createCaseFile.spec.ts
@@ -2,11 +2,14 @@ import { uuid } from 'uuidv4'
import { BadRequestException } from '@nestjs/common'
+import { MessageService, MessageType } from '@island.is/judicial-system/message'
import {
+ CaseFileCategory,
CaseFileState,
indictmentCases,
investigationCases,
restrictionCases,
+ User,
} from '@island.is/judicial-system/types'
import { createTestingFileModule } from '../createTestingFileModule'
@@ -28,12 +31,17 @@ type GivenWhenThen = (
) => Promise
describe('FileController - Create case file', () => {
+ const user = { id: uuid() } as User
+
+ let mockMessageService: MessageService
let mockFileModel: typeof CaseFile
let givenWhenThen: GivenWhenThen
beforeEach(async () => {
- const { fileModel, fileController } = await createTestingFileModule()
+ const { messageService, fileModel, fileController } =
+ await createTestingFileModule()
+ mockMessageService = messageService
mockFileModel = fileModel
givenWhenThen = async (
@@ -44,7 +52,7 @@ describe('FileController - Create case file', () => {
const then = {} as Then
await fileController
- .createCaseFile(caseId, theCase, createCaseFile)
+ .createCaseFile(caseId, user, theCase, createCaseFile)
.then((result) => (then.result = result))
.catch((error) => (then.error = error))
@@ -62,6 +70,7 @@ describe('FileController - Create case file', () => {
type: 'text/plain',
key: `uploads/${caseId}/${uuId}/test.txt`,
size: 99,
+ category: CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT_CASE_FILE,
}
const fileId = uuid()
const timeStamp = randomDate()
@@ -69,6 +78,7 @@ describe('FileController - Create case file', () => {
type: 'text/plain',
key: `uploads/${caseId}/${uuId}/test.txt`,
size: 99,
+ category: CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT_CASE_FILE,
id: fileId,
created: timeStamp,
modified: timeStamp,
@@ -82,19 +92,25 @@ describe('FileController - Create case file', () => {
then = await givenWhenThen(caseId, createCaseFile, theCase)
})
- it('should create a case file in the database', () => {
+ it('should create a case file', () => {
expect(mockFileModel.create).toHaveBeenCalledWith({
type: 'text/plain',
state: CaseFileState.STORED_IN_RVG,
key: `uploads/${caseId}/${uuId}/test.txt`,
size: 99,
+ category: CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT_CASE_FILE,
caseId,
name: 'test.txt',
userGeneratedFilename: 'test.txt',
})
- })
-
- it('should return a case file', () => {
+ expect(mockMessageService.sendMessagesToQueue).toHaveBeenCalledWith([
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId,
+ elementId: fileId,
+ },
+ ])
expect(then.result).toBe(caseFile)
})
},
@@ -128,7 +144,7 @@ describe('FileController - Create case file', () => {
then = await givenWhenThen(caseId, createCaseFile, theCase)
})
- it('should create a case file in the database', () => {
+ it('should create a case file', () => {
expect(mockFileModel.create).toHaveBeenCalledWith({
type: 'text/plain',
state: CaseFileState.STORED_IN_RVG,
@@ -138,9 +154,6 @@ describe('FileController - Create case file', () => {
name: 'test.txt',
userGeneratedFilename: 'test.txt',
})
- })
-
- it('should return a case file', () => {
expect(then.result).toBe(caseFile)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/file/test/fileController/getCaseFileSignedUrl.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/fileController/getCaseFileSignedUrl.spec.ts
index e30a3226fc48..5e158ac58ba2 100644
--- a/apps/judicial-system/backend/src/app/modules/file/test/fileController/getCaseFileSignedUrl.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/test/fileController/getCaseFileSignedUrl.spec.ts
@@ -95,21 +95,21 @@ describe('FileController - Get case file signed url', () => {
const fileId = uuid()
const key = `uploads/${uuid()}/${uuid()}/test.txt`
const caseFile = { id: fileId, key } as CaseFile
- const theCase = {} as Case
- const signedUrl = {} as SignedUrl
+ const theCase = { id: uuid() } as Case
+ const url = uuid()
let then: Then
beforeEach(async () => {
const mockObjectExists = mockAwsS3Service.objectExists as jest.Mock
mockObjectExists.mockResolvedValueOnce(true)
const mockGetSignedUrl = mockAwsS3Service.getSignedUrl as jest.Mock
- mockGetSignedUrl.mockResolvedValueOnce(signedUrl)
+ mockGetSignedUrl.mockResolvedValueOnce(url)
then = await givenWhenThen(caseId, theCase, fileId, caseFile)
})
it('should return the signed url', () => {
- expect(then.result).toBe(signedUrl)
+ expect(then.result).toEqual({ url })
})
})
@@ -126,9 +126,7 @@ describe('FileController - Get case file signed url', () => {
it('should throw not found exceptoin', () => {
expect(then.error).toBeInstanceOf(NotFoundException)
- expect(then.error.message).toBe(
- `File ${fileId} does not exists in AWS S3`,
- )
+ expect(then.error.message).toBe(`File ${fileId} does not exist in AWS S3`)
})
})
@@ -158,9 +156,7 @@ describe('FileController - Get case file signed url', () => {
it('should throw not found exceptoin', () => {
expect(then.error).toBeInstanceOf(NotFoundException)
- expect(then.error.message).toBe(
- `File ${fileId} does not exists in AWS S3`,
- )
+ expect(then.error.message).toBe(`File ${fileId} does not exist in AWS S3`)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/file/test/fileController/uploadCaseFileToCourt.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/fileController/uploadCaseFileToCourt.spec.ts
index 2a4b4eb56532..9bcfe1fc294f 100644
--- a/apps/judicial-system/backend/src/app/modules/file/test/fileController/uploadCaseFileToCourt.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/test/fileController/uploadCaseFileToCourt.spec.ts
@@ -332,9 +332,7 @@ describe('FileController - Upload case file to court', () => {
it('should throw not found exception', () => {
expect(then.error).toBeInstanceOf(NotFoundException)
- expect(then.error.message).toBe(
- `File ${fileId} does not exists in AWS S3`,
- )
+ expect(then.error.message).toBe(`File ${fileId} does not exist in AWS S3`)
})
})
@@ -365,9 +363,7 @@ describe('FileController - Upload case file to court', () => {
it('should throw not found exception', () => {
expect(then.error).toBeInstanceOf(NotFoundException)
- expect(then.error.message).toBe(
- `File ${fileId} does not exists in AWS S3`,
- )
+ expect(then.error.message).toBe(`File ${fileId} does not exist in AWS S3`)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourt.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourt.spec.ts
index 7021d98e3625..67adfa0eec0b 100644
--- a/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourt.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourt.spec.ts
@@ -255,9 +255,7 @@ describe('InternalFileController - Deliver case file to court', () => {
it('should throw not found exception', () => {
expect(then.error).toBeInstanceOf(NotFoundException)
- expect(then.error.message).toBe(
- `File ${fileId} does not exists in AWS S3`,
- )
+ expect(then.error.message).toBe(`File ${fileId} does not exist in AWS S3`)
})
})
@@ -285,9 +283,7 @@ describe('InternalFileController - Deliver case file to court', () => {
it('should throw not found exception', () => {
expect(then.error).toBeInstanceOf(NotFoundException)
- expect(then.error.message).toBe(
- `File ${fileId} does not exists in AWS S3`,
- )
+ expect(then.error.message).toBe(`File ${fileId} does not exist in AWS S3`)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourtOfAppeals.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourtOfAppeals.spec.ts
new file mode 100644
index 000000000000..f2371596e9fd
--- /dev/null
+++ b/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourtOfAppeals.spec.ts
@@ -0,0 +1,119 @@
+import { uuid } from 'uuidv4'
+
+import { ConfigType } from '@nestjs/config'
+
+import {
+ CaseFileCategory,
+ CaseType,
+ User,
+} from '@island.is/judicial-system/types'
+
+import { createTestingFileModule } from '../createTestingFileModule'
+
+import { nowFactory } from '../../../../factories'
+import { AwsS3Service } from '../../../aws-s3'
+import { Case } from '../../../case'
+import { CourtService } from '../../../court'
+import { fileModuleConfig } from '../../file.config'
+import { DeliverResponse } from '../../models/deliver.response'
+import { CaseFile } from '../../models/file.model'
+
+interface Then {
+ result: DeliverResponse
+ error: Error
+}
+
+type GivenWhenThen = () => Promise
+
+describe('InternalFileController - Deliver case file to court of appeals', () => {
+ const user = { id: uuid() } as User
+ const caseId = uuid()
+ const appealCaseNumber = uuid()
+ const caseFileId = uuid()
+ const key = uuid()
+ const category: CaseFileCategory =
+ CaseFileCategory.PROSECUTOR_APPEAL_STATEMENT
+ const name = uuid()
+ const created = nowFactory()
+ const url = uuid()
+ const caseFile = {
+ id: caseFileId,
+ caseId,
+ created,
+ category,
+ name,
+ key,
+ } as CaseFile
+ const theCase = {
+ id: caseId,
+ type: CaseType.CUSTODY,
+ appealCaseNumber,
+ caseFiles: [caseFile],
+ } as Case
+
+ let mockAwsS3Service: AwsS3Service
+ let mockCourtService: CourtService
+ let mockFileConfig: ConfigType
+ let givenWhenThen: GivenWhenThen
+
+ beforeEach(async () => {
+ const { awsS3Service, courtService, fileConfig, internalFileController } =
+ await createTestingFileModule()
+
+ mockAwsS3Service = awsS3Service
+ mockCourtService = courtService
+ mockFileConfig = fileConfig
+
+ const mockObjectExists = mockAwsS3Service.objectExists as jest.Mock
+ mockObjectExists.mockResolvedValueOnce(true)
+ const mockGetSignedUrl = mockAwsS3Service.getSignedUrl as jest.Mock
+ mockGetSignedUrl.mockResolvedValue(url)
+ const mockUpdateAppealCaseWithFile =
+ mockCourtService.updateAppealCaseWithFile as jest.Mock
+ mockUpdateAppealCaseWithFile.mockResolvedValue(uuid())
+
+ givenWhenThen = async () => {
+ const then = {} as Then
+
+ await internalFileController
+ .deliverCaseFileToCourtOfAppeals(
+ caseId,
+ caseFileId,
+ theCase,
+ caseFile,
+ { user },
+ )
+ .then((result) => (then.result = result))
+ .catch((error) => (then.error = error))
+
+ return then
+ }
+ })
+
+ describe('case file delivered', () => {
+ let then: Then
+
+ beforeEach(async () => {
+ then = await givenWhenThen()
+ })
+
+ it('should return success', () => {
+ expect(mockAwsS3Service.objectExists).toHaveBeenCalledWith(key)
+ expect(mockAwsS3Service.getSignedUrl).toHaveBeenCalledWith(
+ key,
+ mockFileConfig.robotS3TimeToLiveGet,
+ )
+ expect(mockCourtService.updateAppealCaseWithFile).toHaveBeenCalledWith(
+ user,
+ caseId,
+ caseFileId,
+ appealCaseNumber,
+ category,
+ name,
+ url,
+ created,
+ )
+ expect(then.result).toEqual({ delivered: true })
+ })
+ })
+})
diff --git a/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourtOfAppealsGuards.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourtOfAppealsGuards.spec.ts
new file mode 100644
index 000000000000..bfa9043ec233
--- /dev/null
+++ b/apps/judicial-system/backend/src/app/modules/file/test/internalFileController/deliverCaseFileToCourtOfAppealsGuards.spec.ts
@@ -0,0 +1,21 @@
+import { CaseExistsGuard } from '../../../case'
+import { CaseFileExistsGuard } from '../../guards/caseFileExists.guard'
+import { InternalFileController } from '../../internalFile.controller'
+
+describe('InternalCaseController - Deliver case file to court of appeals guards', () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let guards: any[]
+
+ beforeEach(() => {
+ guards = Reflect.getMetadata(
+ '__guards__',
+ InternalFileController.prototype.deliverCaseFileToCourtOfAppeals,
+ )
+ })
+
+ it('should have the right guard configuration', () => {
+ expect(guards).toHaveLength(2)
+ expect(new guards[0]()).toBeInstanceOf(CaseExistsGuard)
+ expect(new guards[1]()).toBeInstanceOf(CaseFileExistsGuard)
+ })
+})
diff --git a/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/createCaseFile.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/createCaseFile.spec.ts
index 1e7659bbba46..0be53642be0c 100644
--- a/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/createCaseFile.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/createCaseFile.spec.ts
@@ -2,11 +2,14 @@ import { uuid } from 'uuidv4'
import { BadRequestException } from '@nestjs/common'
+import { MessageService, MessageType } from '@island.is/judicial-system/message'
import {
+ CaseFileCategory,
CaseFileState,
indictmentCases,
investigationCases,
restrictionCases,
+ User,
} from '@island.is/judicial-system/types'
import { createTestingFileModule } from '../createTestingFileModule'
@@ -28,13 +31,17 @@ type GivenWhenThen = (
) => Promise
describe('limitedAccessFileController - Create case file', () => {
+ const user = { id: uuid() } as User
+
+ let mockMessageService: MessageService
let mockFileModel: typeof CaseFile
let givenWhenThen: GivenWhenThen
beforeEach(async () => {
- const { fileModel, limitedAccessFileController } =
+ const { messageService, fileModel, limitedAccessFileController } =
await createTestingFileModule()
+ mockMessageService = messageService
mockFileModel = fileModel
givenWhenThen = async (
@@ -45,7 +52,7 @@ describe('limitedAccessFileController - Create case file', () => {
const then = {} as Then
await limitedAccessFileController
- .createCaseFile(caseId, theCase, createCaseFile)
+ .createCaseFile(caseId, user, theCase, createCaseFile)
.then((result) => (then.result = result))
.catch((error) => (then.error = error))
@@ -63,6 +70,7 @@ describe('limitedAccessFileController - Create case file', () => {
type: 'text/plain',
key: `uploads/${caseId}/${uuId}/test.txt`,
size: 99,
+ category: CaseFileCategory.DEFENDANT_APPEAL_CASE_FILE,
}
const fileId = uuid()
const timeStamp = randomDate()
@@ -70,6 +78,7 @@ describe('limitedAccessFileController - Create case file', () => {
type: 'text/plain',
key: `uploads/${caseId}/${uuId}/test.txt`,
size: 99,
+ category: CaseFileCategory.DEFENDANT_APPEAL_CASE_FILE,
id: fileId,
created: timeStamp,
modified: timeStamp,
@@ -83,19 +92,25 @@ describe('limitedAccessFileController - Create case file', () => {
then = await givenWhenThen(caseId, createCaseFile, theCase)
})
- it('should create a case file in the database', () => {
+ it('should create a case file', () => {
expect(mockFileModel.create).toHaveBeenCalledWith({
type: 'text/plain',
state: CaseFileState.STORED_IN_RVG,
key: `uploads/${caseId}/${uuId}/test.txt`,
size: 99,
+ category: CaseFileCategory.DEFENDANT_APPEAL_CASE_FILE,
caseId,
name: 'test.txt',
userGeneratedFilename: 'test.txt',
})
- })
-
- it('should return a case file', () => {
+ expect(mockMessageService.sendMessagesToQueue).toHaveBeenCalledWith([
+ {
+ type: MessageType.DELIVERY_TO_COURT_OF_APPEALS_CASE_FILE,
+ user,
+ caseId,
+ elementId: fileId,
+ },
+ ])
expect(then.result).toBe(caseFile)
})
},
@@ -129,7 +144,7 @@ describe('limitedAccessFileController - Create case file', () => {
then = await givenWhenThen(caseId, createCaseFile, theCase)
})
- it('should create a case file in the database', () => {
+ it('should create a case file', () => {
expect(mockFileModel.create).toHaveBeenCalledWith({
type: 'text/plain',
state: CaseFileState.STORED_IN_RVG,
@@ -139,9 +154,6 @@ describe('limitedAccessFileController - Create case file', () => {
name: 'test.txt',
userGeneratedFilename: 'test.txt',
})
- })
-
- it('should return a case file', () => {
expect(then.result).toBe(caseFile)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/getCaseFileSignedUrl.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/getCaseFileSignedUrl.spec.ts
index c9447ed6383a..90d0ae806cb9 100644
--- a/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/getCaseFileSignedUrl.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/getCaseFileSignedUrl.spec.ts
@@ -98,20 +98,20 @@ describe('LimitedAccessFileController - Get case file signed url', () => {
const caseFile = { id: fileId, key } as CaseFile
const theCase = {} as Case
- const signedUrl = {} as SignedUrl
+ const url = uuid()
let then: Then
beforeEach(async () => {
const mockObjectExists = mockAwsS3Service.objectExists as jest.Mock
mockObjectExists.mockResolvedValueOnce(true)
const mockGetSignedUrl = mockAwsS3Service.getSignedUrl as jest.Mock
- mockGetSignedUrl.mockResolvedValueOnce(signedUrl)
+ mockGetSignedUrl.mockResolvedValueOnce(url)
then = await givenWhenThen(caseId, theCase, fileId, caseFile)
})
it('should return the signed url', () => {
- expect(then.result).toBe(signedUrl)
+ expect(then.result).toEqual({ url })
})
})
@@ -129,9 +129,7 @@ describe('LimitedAccessFileController - Get case file signed url', () => {
it('should throw not found exceptoin', () => {
expect(then.error).toBeInstanceOf(NotFoundException)
- expect(then.error.message).toBe(
- `File ${fileId} does not exists in AWS S3`,
- )
+ expect(then.error.message).toBe(`File ${fileId} does not exist in AWS S3`)
})
})
@@ -162,9 +160,7 @@ describe('LimitedAccessFileController - Get case file signed url', () => {
it('should throw not found exceptoin', () => {
expect(then.error).toBeInstanceOf(NotFoundException)
- expect(then.error.message).toBe(
- `File ${fileId} does not exists in AWS S3`,
- )
+ expect(then.error.message).toBe(`File ${fileId} does not exist in AWS S3`)
})
})
diff --git a/apps/judicial-system/backend/src/app/modules/index.ts b/apps/judicial-system/backend/src/app/modules/index.ts
index febb69eea5d9..95a03b26fdc4 100644
--- a/apps/judicial-system/backend/src/app/modules/index.ts
+++ b/apps/judicial-system/backend/src/app/modules/index.ts
@@ -11,6 +11,7 @@ export { IndictmentCountModule } from './indictment-count/indictmentCount.module
export { UserModule } from './user/user.module'
export { InstitutionModule } from './institution/institution.module'
export { FileModule } from './file/file.module'
+export { fileModuleConfig } from './file/file.config'
export { NotificationModule } from './notification/notification.module'
export { PoliceModule } from './police/police.module'
export { CourtModule } from './court/court.module'
diff --git a/apps/judicial-system/backend/src/app/modules/notification/notification.service.ts b/apps/judicial-system/backend/src/app/modules/notification/notification.service.ts
index 2cd531b7dc8e..0f826e410ffe 100644
--- a/apps/judicial-system/backend/src/app/modules/notification/notification.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/notification/notification.service.ts
@@ -40,6 +40,8 @@ import {
CaseDecision,
CaseState,
CaseType,
+ DateType,
+ getLatestDateType,
getStatementDeadline,
isDefenceUser,
isIndictmentCase,
@@ -75,7 +77,7 @@ import {
} from '../../formatters'
import { formatCourtOfAppealJudgeAssignedEmailNotification } from '../../formatters/formatters'
import { notifications } from '../../messages'
-import { Case } from '../case'
+import { Case, DateLog } from '../case'
import { CourtService } from '../court'
import { Defendant, DefendantService } from '../defendant'
import { CaseEvent, EventService } from '../event'
@@ -310,43 +312,50 @@ export class NotificationService {
}
private createICalAttachment(theCase: Case): Attachment | undefined {
- if (theCase.courtDate) {
- const eventOrganizer = {
- name: theCase.registrar
- ? theCase.registrar.name
- : theCase.judge
- ? theCase.judge.name
- : '',
- email: theCase.registrar
- ? theCase.registrar.email
- : theCase.judge
- ? theCase.judge.email
- : '',
- }
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ theCase.dateLogs,
+ ) as DateLog
- const courtDate = new Date(theCase.courtDate.toString().split('.')[0])
- const courtEnd = new Date(theCase.courtDate.getTime() + 30 * 60000)
-
- const icalendar = new ICalendar({
- title: `Fyrirtaka í máli ${theCase.courtCaseNumber} - ${theCase.prosecutorsOffice?.name} gegn X`,
- location: `${theCase.court?.name} - ${
- theCase.courtRoom
- ? `Dómsalur ${theCase.courtRoom}`
- : 'Dómsalur hefur ekki verið skráður.'
- }`,
- start: courtDate,
- end: courtEnd,
- })
+ if (!courtDate) {
+ return
+ }
- return {
- filename: 'court-date.ics',
- content: icalendar
- .addProperty(
- `ORGANIZER;CN=${eventOrganizer.name}`,
- `MAILTO:${eventOrganizer.email}`,
- )
- .render(),
- }
+ const eventOrganizer = {
+ name: theCase.registrar
+ ? theCase.registrar.name
+ : theCase.judge
+ ? theCase.judge.name
+ : '',
+ email: theCase.registrar
+ ? theCase.registrar.email
+ : theCase.judge
+ ? theCase.judge.email
+ : '',
+ }
+
+ const courtDateStart = new Date(courtDate.date.toString().split('.')[0])
+ const courtDateEnd = new Date(courtDate.date.getTime() + 30 * 60000)
+
+ const icalendar = new ICalendar({
+ title: `Fyrirtaka í máli ${theCase.courtCaseNumber} - ${theCase.prosecutorsOffice?.name} gegn X`,
+ location: `${theCase.court?.name} - ${
+ theCase.courtRoom
+ ? `Dómsalur ${theCase.courtRoom}`
+ : 'Dómsalur hefur ekki verið skráður.'
+ }`,
+ start: courtDateStart,
+ end: courtDateEnd,
+ })
+
+ return {
+ filename: 'court-date.ics',
+ content: icalendar
+ .addProperty(
+ `ORGANIZER;CN=${eventOrganizer.name}`,
+ `MAILTO:${eventOrganizer.email}`,
+ )
+ .render(),
}
}
@@ -608,22 +617,28 @@ export class NotificationService {
}
}
- private async sendCourtDateEmailNotificationToProsecutor(
+ private sendCourtDateEmailNotificationToProsecutor(
theCase: Case,
user: User,
): Promise {
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ theCase.dateLogs,
+ ) as DateLog
+
const { subject, body } = formatProsecutorCourtDateEmailNotification(
this.formatMessage,
theCase.type,
theCase.courtCaseNumber,
theCase.court?.name,
- theCase.courtDate,
+ courtDate?.date,
theCase.courtRoom,
theCase.judge?.name,
theCase.registrar?.name,
theCase.defenderName,
theCase.sessionArrangements,
)
+
const calendarInvite =
theCase.sessionArrangements === SessionArrangements.NONE_PRESENT
? undefined
@@ -651,20 +666,26 @@ export class NotificationService {
})
}
- private async sendCourtDateEmailNotificationToPrison(
+ private sendCourtDateEmailNotificationToPrison(
theCase: Case,
): Promise {
const subject = this.formatMessage(
notifications.prisonCourtDateEmail.subject,
{ caseType: theCase.type, courtCaseNumber: theCase.courtCaseNumber },
)
+
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ theCase.dateLogs,
+ ) as DateLog
+
// Assume there is at most one defendant
const html = formatPrisonCourtDateEmailNotification(
this.formatMessage,
theCase.type,
theCase.prosecutorsOffice?.name,
theCase.court?.name,
- theCase.courtDate,
+ courtDate?.date,
theCase.defendants && theCase.defendants.length > 0
? theCase.defendants[0].gender
: undefined,
@@ -690,6 +711,10 @@ export class NotificationService {
theCase: Case,
user: User,
): Promise {
+ const courtDate = theCase.dateLogs?.find(
+ (dateLog) => (dateLog.dateType = DateType.COURT_DATE),
+ )
+
const subject = `Fyrirtaka í máli ${theCase.courtCaseNumber}`
const calendarInvite = this.createICalAttachment(theCase)
@@ -697,7 +722,7 @@ export class NotificationService {
this.formatMessage,
theCase.court?.name,
theCase.courtCaseNumber,
- theCase.courtDate,
+ courtDate?.date,
theCase.courtRoom,
theCase.judge?.name,
theCase.registrar?.name,
@@ -1186,12 +1211,17 @@ export class NotificationService {
}
private sendRevokedSmsNotificationToCourt(theCase: Case): Promise {
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ theCase.dateLogs,
+ ) as DateLog
+
const smsText = formatCourtRevokedSmsNotification(
this.formatMessage,
theCase.type,
theCase.prosecutor?.name,
theCase.requestedCourtDate,
- theCase.courtDate,
+ courtDate?.date,
)
return this.sendSms(smsText, this.getCourtMobileNumbers(theCase.courtId))
@@ -1200,17 +1230,23 @@ export class NotificationService {
private sendRevokedEmailNotificationToPrison(
theCase: Case,
): Promise {
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ theCase.dateLogs,
+ ) as DateLog
+
const subject = this.formatMessage(
notifications.prisonRevokedEmail.subject,
{ caseType: theCase.type, courtCaseNumber: theCase.courtCaseNumber },
)
+
// Assume there is at most one defendant
const html = formatPrisonRevokedEmailNotification(
this.formatMessage,
theCase.type,
theCase.prosecutorsOffice?.name,
theCase.court?.name,
- theCase.courtDate,
+ courtDate?.date,
theCase.defenderName,
Boolean(theCase.parentCase),
theCase.courtCaseNumber,
@@ -1255,6 +1291,10 @@ export class NotificationService {
theCase: Case,
): Promise {
const promises: Promise[] = []
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ theCase.dateLogs,
+ ) as DateLog
const courtWasNotified =
!isIndictmentCase(theCase.type) &&
@@ -1294,7 +1334,7 @@ export class NotificationService {
defendant,
defendant.defenderName,
defendant.defenderEmail,
- theCase.courtDate,
+ courtDate?.date,
theCase.court?.name,
),
)
@@ -1313,7 +1353,7 @@ export class NotificationService {
theCase.defendants[0],
theCase.defenderName,
theCase.defenderEmail,
- theCase.courtDate,
+ courtDate?.date,
theCase.court?.name,
),
)
@@ -1407,6 +1447,10 @@ export class NotificationService {
theCase: Case,
): Promise {
const promises: Promise[] = []
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ theCase.dateLogs,
+ ) as DateLog
if (isIndictmentCase(theCase.type)) {
const uniqDefendants = _uniqBy(
@@ -1432,7 +1476,7 @@ export class NotificationService {
)
}
}
- } else if (theCase.courtDate) {
+ } else if (courtDate?.date) {
const shouldSend = await this.shouldSendDefenderAssignedNotification(
theCase,
theCase.defenderEmail,
@@ -1538,6 +1582,40 @@ export class NotificationService {
}
//#endregion
+ //#region INDICTMENT_RETURNED notifications
+ private async sendIndictmentReturnedNotifications(
+ theCase: Case,
+ ): Promise {
+ const subject = this.formatMessage(
+ notifications.indictmentReturned.subject,
+ {
+ caseNumber: theCase.policeCaseNumbers[0],
+ },
+ )
+ const html = this.formatMessage(notifications.indictmentReturned.body, {
+ courtName: theCase.court?.name,
+ caseNumber: theCase.policeCaseNumbers[0],
+ linkStart: ``,
+ linkEnd: ' ',
+ })
+
+ const recipient = await this.sendEmail(
+ subject,
+ html,
+ theCase.prosecutor?.name,
+ theCase.prosecutor?.email,
+ undefined,
+ true,
+ )
+
+ return this.recordNotification(
+ theCase.id,
+ NotificationType.INDICTMENT_RETURNED,
+ [recipient],
+ )
+ }
+ //#endregion
+
//#region Appeal notifications
//#region COURT_OF_APPEAL_JUDGE_ASSIGNED notifications
private async sendCourtOfAppealJudgeAssignedNotification(
@@ -2358,6 +2436,8 @@ export class NotificationService {
return this.sendAppealWithdrawnNotifications(theCase, user)
case NotificationType.INDICTMENT_DENIED:
return this.sendIndictmentDeniedNotifications(theCase)
+ case NotificationType.INDICTMENT_RETURNED:
+ return this.sendIndictmentReturnedNotifications(theCase)
}
}
diff --git a/apps/judicial-system/backend/src/app/modules/notification/test/internalNotificationController/sendDefenderAssignedNotifications.spec.ts b/apps/judicial-system/backend/src/app/modules/notification/test/internalNotificationController/sendDefenderAssignedNotifications.spec.ts
index 7b23c248b40f..d63d3f9da009 100644
--- a/apps/judicial-system/backend/src/app/modules/notification/test/internalNotificationController/sendDefenderAssignedNotifications.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/notification/test/internalNotificationController/sendDefenderAssignedNotifications.spec.ts
@@ -9,6 +9,7 @@ import {
} from '@island.is/judicial-system/consts'
import {
CaseType,
+ DateType,
NotificationType,
User,
} from '@island.is/judicial-system/types'
@@ -357,7 +358,7 @@ describe('InternalNotificationController - Send defender assigned notifications'
defenderEmail: 'recipient@gmail.com',
defenderName: 'John Doe',
defenderNationalId: '1234567890',
- courtDate: new Date(),
+ dateLogs: [{ date: new Date(), dateType: DateType.COURT_DATE }],
} as Case
beforeEach(async () => {
@@ -403,7 +404,7 @@ describe('InternalNotificationController - Send defender assigned notifications'
courtCaseNumber: 'R-123/2022',
defenderEmail: 'recipient@gmail.com',
defenderName: 'John Doe',
- courtDate: new Date(),
+ dateLogs: [{ date: new Date(), dateType: DateType.COURT_DATE }],
} as Case
beforeEach(async () => {
@@ -449,7 +450,7 @@ describe('InternalNotificationController - Send defender assigned notifications'
courtCaseNumber: 'R-123/2022',
defenderEmail: 'recipient@gmail.com',
defenderName: 'John Doe',
- courtDate: new Date(),
+ dateLogs: [{ date: new Date(), dateType: DateType.COURT_DATE }],
} as Case
beforeEach(async () => {
diff --git a/apps/judicial-system/backend/src/app/modules/notification/test/internalNotificationController/sendIndictmentReturnedNotifications.spec.ts b/apps/judicial-system/backend/src/app/modules/notification/test/internalNotificationController/sendIndictmentReturnedNotifications.spec.ts
new file mode 100644
index 000000000000..86ba67ab84f4
--- /dev/null
+++ b/apps/judicial-system/backend/src/app/modules/notification/test/internalNotificationController/sendIndictmentReturnedNotifications.spec.ts
@@ -0,0 +1,102 @@
+import { uuid } from 'uuidv4'
+
+import { EmailService } from '@island.is/email-service'
+
+import {
+ CaseType,
+ NotificationType,
+ User,
+} from '@island.is/judicial-system/types'
+
+import { createTestingNotificationModule } from '../createTestingNotificationModule'
+
+import { Case } from '../../../case'
+import { SendInternalNotificationDto } from '../../dto/sendInternalNotification.dto'
+import { DeliverResponse } from '../../models/deliver.response'
+import { Notification } from '../../models/notification.model'
+
+jest.mock('../../../../factories')
+
+interface Then {
+ result: DeliverResponse
+ error: Error
+}
+
+type GivenWhenThen = (
+ theCase: Case,
+ notificationDto: SendInternalNotificationDto,
+) => Promise
+
+describe('InternalNotificationController - Send indictment returned notification', () => {
+ const userId = uuid()
+ const caseId = uuid()
+ const prosecutorName = uuid()
+ const prosecutorEmail = uuid()
+ const policeCaseNumbers = [uuid(), uuid()]
+ const courtName = uuid()
+
+ let mockEmailService: EmailService
+ let mockNotificationModel: typeof Notification
+ let givenWhenThen: GivenWhenThen
+
+ beforeEach(async () => {
+ const { emailService, internalNotificationController, notificationModel } =
+ await createTestingNotificationModule()
+
+ mockEmailService = emailService
+ mockNotificationModel = notificationModel
+
+ const mockFindAll = mockNotificationModel.findAll as jest.Mock
+ mockFindAll.mockResolvedValue([])
+
+ givenWhenThen = async (
+ theCase: Case,
+ notificationDto: SendInternalNotificationDto,
+ ) => {
+ const then = {} as Then
+
+ await internalNotificationController
+ .sendCaseNotification(caseId, theCase, notificationDto)
+ .then((result) => (then.result = result))
+ .catch((error) => (then.error = error))
+
+ return then
+ }
+ })
+
+ describe('notification sent', () => {
+ let then: Then
+
+ const notificationDto: SendInternalNotificationDto = {
+ user: { id: userId } as User,
+ type: NotificationType.INDICTMENT_RETURNED,
+ }
+
+ const theCase = {
+ id: caseId,
+ type: CaseType.INDICTMENT,
+ prosecutor: { name: prosecutorName, email: prosecutorEmail },
+ policeCaseNumbers,
+ court: { name: courtName },
+ } as Case
+
+ beforeEach(async () => {
+ const mockFindAll = mockNotificationModel.findAll as jest.Mock
+ mockFindAll.mockResolvedValueOnce([])
+
+ then = await givenWhenThen(theCase, notificationDto)
+ })
+
+ it('should send notifications to prosecutor', () => {
+ expect(mockEmailService.sendEmail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: [{ name: prosecutorName, address: prosecutorEmail }],
+ subject: `Ákæra endursend í máli ${policeCaseNumbers[0]}`,
+ html: `${courtName} hefur endursent ákæru vegna lögreglumáls ${policeCaseNumbers[0]}. Þú getur nálgast samantekt málsins á yfirlitssíðu málsins í Réttarvörslugátt. `,
+ }),
+ )
+
+ expect(then.result).toEqual({ delivered: true })
+ })
+ })
+})
diff --git a/apps/judicial-system/backend/src/app/modules/police/police.service.ts b/apps/judicial-system/backend/src/app/modules/police/police.service.ts
index 7908f3f081ed..461c672944b1 100644
--- a/apps/judicial-system/backend/src/app/modules/police/police.service.ts
+++ b/apps/judicial-system/backend/src/app/modules/police/police.service.ts
@@ -6,6 +6,7 @@ import { z } from 'zod'
import {
BadGatewayException,
+ forwardRef,
Inject,
Injectable,
NotFoundException,
@@ -60,6 +61,28 @@ const getChapter = (category?: string): number | undefined => {
return +chapter[1] - 1
}
+const formatCrimeScenePlace = (
+ street?: string,
+ streetNumber?: string,
+ municipality?: string,
+) => {
+ if (!street && !municipality) {
+ return ''
+ }
+
+ // Format the street and street number
+ const formattedStreet =
+ street && streetNumber ? `${street} ${streetNumber}` : street
+
+ // Format the municipality
+ const formattedMunicipality =
+ municipality && street ? `, ${municipality}` : municipality
+
+ const address = `${formattedStreet ?? ''}${formattedMunicipality ?? ''}`
+
+ return address.trim()
+}
+
@Injectable()
export class PoliceService {
private xRoadPath: string
@@ -78,6 +101,10 @@ export class PoliceService {
brotFra: z.optional(z.string()),
upprunalegtMalsnumer: z.string(),
licencePlate: z.optional(z.string()),
+ gotuHeiti: z.optional(z.string()),
+ gotuNumer: z.optional(z.string()),
+ sveitafelag: z.optional(z.string()),
+ postnumer: z.optional(z.string()),
})
private responseStructure = z.object({
malsnumer: z.string(),
@@ -88,7 +115,9 @@ export class PoliceService {
constructor(
@Inject(policeModuleConfig.KEY)
private readonly config: ConfigType,
+ @Inject(forwardRef(() => EventService))
private readonly eventService: EventService,
+ @Inject(forwardRef(() => AwsS3Service))
private readonly awsS3Service: AwsS3Service,
@Inject(LOGGER_PROVIDER) private readonly logger: Logger,
) {
@@ -307,9 +336,17 @@ export class PoliceService {
vettvangur?: string
brotFra?: string
licencePlate?: string
+ gotuHeiti?: string
+ gotuNumer?: string
+ sveitafelag?: string
}) => {
const policeCaseNumber = info.upprunalegtMalsnumer
- const place = (info.vettvangur || '').trim()
+
+ const place = formatCrimeScenePlace(
+ info.gotuHeiti,
+ info.gotuNumer,
+ info.sveitafelag,
+ )
const date = info.brotFra ? new Date(info.brotFra) : undefined
const licencePlate = info.licencePlate
@@ -393,6 +430,7 @@ export class PoliceService {
caseType: CaseType,
caseState: CaseState,
policeCaseNumber: string,
+ courtCaseNumber: string,
defendantNationalId: string,
validToDate: Date,
caseConclusion: string,
@@ -412,6 +450,7 @@ export class PoliceService {
body: JSON.stringify({
rvMal_ID: caseId,
caseNumber: policeCaseNumber,
+ courtCaseNumber,
ssn: defendantNationalId,
type: caseType,
courtVerdict: caseState,
@@ -450,6 +489,7 @@ export class PoliceService {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
},
reason,
)
diff --git a/apps/judicial-system/backend/src/app/modules/police/test/getPoliceCaseInfo.spec.ts b/apps/judicial-system/backend/src/app/modules/police/test/getPoliceCaseInfo.spec.ts
index 1ccef8c5d06e..db80aab35b8b 100644
--- a/apps/judicial-system/backend/src/app/modules/police/test/getPoliceCaseInfo.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/police/test/getPoliceCaseInfo.spec.ts
@@ -97,11 +97,22 @@ describe('PoliceController - Get police case info', () => {
{
upprunalegtMalsnumer: '007-2021-000001',
brotFra: '2021-02-23T13:17:00',
- vettvangur: 'Testgata 1, 101',
licencePlate: 'ABC-123',
+ gotuHeiti: 'Testgata',
+ gotuNumer: '3',
+ sveitafelag: 'Testbær',
},
{
upprunalegtMalsnumer: '007-2020-000103',
+ brotFra: '2021-02-23T13:17:00',
+ gotuHeiti: 'Teststígur',
+ sveitafelag: 'Testbær',
+ licencePlate: 'CDE-123',
+ },
+ {
+ upprunalegtMalsnumer: '007-2020-000057',
+ brotFra: '2021-02-23T13:17:00',
+ gotuHeiti: 'Teststígur',
},
],
}),
@@ -114,12 +125,22 @@ describe('PoliceController - Get police case info', () => {
expect(then.result).toEqual([
{
policeCaseNumber: '007-2021-000001',
- place: 'Testgata 1, 101',
+ place: 'Testgata 3, Testbær',
date: new Date('2021-02-23T13:17:00'),
licencePlate: 'ABC-123',
},
- { policeCaseNumber: '007-2020-000103' },
- { policeCaseNumber: '007-2020-000057' },
+ {
+ policeCaseNumber: '007-2020-000103',
+ date: new Date('2021-02-23T13:17:00'),
+ place: 'Teststígur, Testbær',
+ licencePlate: 'CDE-123',
+ },
+ {
+ date: new Date('2021-02-23T13:17:00'),
+ policeCaseNumber: '007-2020-000057',
+ place: 'Teststígur',
+ licencePlate: undefined,
+ },
{ policeCaseNumber: '008-2013-000033' },
])
})
diff --git a/apps/judicial-system/backend/src/app/modules/police/test/updatePoliceCase.spec.ts b/apps/judicial-system/backend/src/app/modules/police/test/updatePoliceCase.spec.ts
index ed167e5b1685..961783990ed6 100644
--- a/apps/judicial-system/backend/src/app/modules/police/test/updatePoliceCase.spec.ts
+++ b/apps/judicial-system/backend/src/app/modules/police/test/updatePoliceCase.spec.ts
@@ -31,6 +31,7 @@ describe('PoliceController - Update Police Case', () => {
const caseType = CaseType.CUSTODY
const caseState = CaseState.ACCEPTED
const policeCaseNumber = uuid()
+ const courtCaseNumber = uuid()
const defendantNationalId = uuid()
const validToDate = randomDate()
const caseConclusion = 'test conclusion'
@@ -70,6 +71,7 @@ describe('PoliceController - Update Police Case', () => {
caseType,
caseState,
policeCaseNumber,
+ courtCaseNumber,
defendantNationalId,
validToDate,
caseConclusion,
@@ -104,6 +106,7 @@ describe('PoliceController - Update Police Case', () => {
body: JSON.stringify({
rvMal_ID: caseId,
caseNumber: policeCaseNumber,
+ courtCaseNumber,
ssn: defendantNationalId,
type: caseType,
courtVerdict: caseState,
diff --git a/apps/judicial-system/backend/src/app/test/random.ts b/apps/judicial-system/backend/src/app/test/random.ts
index d0bad9bc3289..20e5c9d8fef6 100644
--- a/apps/judicial-system/backend/src/app/test/random.ts
+++ b/apps/judicial-system/backend/src/app/test/random.ts
@@ -9,14 +9,14 @@ export const randomDate = () => {
export const randomBoolean = () => Math.random() >= 0.5
-export function randomEnum(anEnum: T): T[keyof T] {
+export const randomEnum = (anEnum: T): T[keyof T] => {
const enumValues = Object.keys(anEnum as never) as unknown as T[keyof T][]
const randomIndex = Math.floor(Math.random() * enumValues.length)
const randomEnumValue = enumValues[randomIndex]
return randomEnumValue
}
-export function randomEnumSplit(anEnum: T): [T[keyof T][], T[keyof T][]] {
+export const randomEnumSplit = (anEnum: T): [T[keyof T][], T[keyof T][]] => {
const selected = []
const keys = Object.keys(anEnum as never) as unknown as T[keyof T][]
let remaining = keys
diff --git a/apps/judicial-system/web/src/components/AppealCaseFilesOverview/AppealCaseFilesOverview.spec.tsx b/apps/judicial-system/web/src/components/AppealCaseFilesOverview/AppealCaseFilesOverview.spec.tsx
index 44d26ce2afbd..7706abe7bc19 100644
--- a/apps/judicial-system/web/src/components/AppealCaseFilesOverview/AppealCaseFilesOverview.spec.tsx
+++ b/apps/judicial-system/web/src/components/AppealCaseFilesOverview/AppealCaseFilesOverview.spec.tsx
@@ -5,11 +5,10 @@ import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { CaseState } from '@island.is/judicial-system/types'
import {
CaseAppealState,
- CaseFile,
CaseFileCategory,
+ CaseState,
CaseType,
UserRole,
} from '@island.is/judicial-system-web/src/graphql/schema'
diff --git a/apps/judicial-system/web/src/components/CaseResubmitModal/CaseResubmitModal.spec.tsx b/apps/judicial-system/web/src/components/CaseResubmitModal/CaseResubmitModal.spec.tsx
index f2173da7690f..15bfadca9abb 100644
--- a/apps/judicial-system/web/src/components/CaseResubmitModal/CaseResubmitModal.spec.tsx
+++ b/apps/judicial-system/web/src/components/CaseResubmitModal/CaseResubmitModal.spec.tsx
@@ -1,6 +1,9 @@
import { createIntl } from 'react-intl'
-import { RequestSharedWithDefender } from '@island.is/judicial-system-web/src/graphql/schema'
+import {
+ DateType,
+ RequestSharedWithDefender,
+} from '@island.is/judicial-system-web/src/graphql/schema'
import { TempCase as Case } from '@island.is/judicial-system-web/src/types'
import { getCaseResubmittedText } from './CaseResubmitModal'
@@ -15,8 +18,16 @@ describe('getCaseResubmittedText', () => {
test('should format correctly when court date has been set and defender is set to receive access when the court date is set', () => {
const theCase = {
- courtDate: '2022-06-13T13:37:00Z',
+ id: 'abc',
requestSharedWithDefender: RequestSharedWithDefender.COURT_DATE,
+ dateLogs: [
+ {
+ caseId: 'abc',
+ created: '2022-06-13',
+ date: '2022-06-13T13:37:00Z',
+ dateType: DateType.COURT_DATE,
+ },
+ ],
} as Case
const res = fn(theCase)
@@ -27,14 +38,19 @@ describe('getCaseResubmittedText', () => {
})
it.each`
- courtDate | requestSharedWithDefender
- ${undefined} | ${RequestSharedWithDefender.COURT_DATE}
- ${'2022-06-13T13:37:00Z'} | ${undefined}
- ${undefined} | ${undefined}
+ id | dateLogs | requestSharedWithDefender
+ ${'abc'} | ${undefined} | ${RequestSharedWithDefender.COURT_DATE}
+ ${'abc'} | ${[{
+ caseId: 'abc',
+ date: '2022-06-13T13:37:00Z',
+ created: '2022-06-13',
+ dateType: DateType.COURT_DATE,
+ }]} | ${undefined}
+ ${'abc'} | ${undefined} | ${undefined}
`(
'should not include section about notification',
- ({ courtDate, requestSharedWithDefender }) => {
- const theCase = { courtDate, requestSharedWithDefender } as Case
+ ({ id, dateLogs, requestSharedWithDefender }) => {
+ const theCase = { id, dateLogs, requestSharedWithDefender } as Case
const res = fn(theCase)
diff --git a/apps/judicial-system/web/src/components/CaseResubmitModal/CaseResubmitModal.tsx b/apps/judicial-system/web/src/components/CaseResubmitModal/CaseResubmitModal.tsx
index d39ef91b43a9..626efb208ce3 100644
--- a/apps/judicial-system/web/src/components/CaseResubmitModal/CaseResubmitModal.tsx
+++ b/apps/judicial-system/web/src/components/CaseResubmitModal/CaseResubmitModal.tsx
@@ -2,7 +2,11 @@ import React, { useState } from 'react'
import { IntlShape, useIntl } from 'react-intl'
import { Box, Input } from '@island.is/island-ui/core'
-import { RequestSharedWithDefender } from '@island.is/judicial-system-web/src/graphql/schema'
+import { getLatestDateType } from '@island.is/judicial-system/types'
+import {
+ DateType,
+ RequestSharedWithDefender,
+} from '@island.is/judicial-system-web/src/graphql/schema'
import { TempCase as Case } from '@island.is/judicial-system-web/src/types'
import { Modal } from '..'
@@ -18,11 +22,13 @@ export const getCaseResubmittedText = (
formatMessage: IntlShape['formatMessage'],
workingCase: Case,
) => {
+ const courtDate = getLatestDateType(DateType.COURT_DATE, workingCase.dateLogs)
+
return formatMessage(strings.text, {
requestSharedWithDefender:
(workingCase.requestSharedWithDefender ===
RequestSharedWithDefender.COURT_DATE &&
- Boolean(workingCase.courtDate)) ||
+ Boolean(courtDate)) ||
workingCase.requestSharedWithDefender ===
RequestSharedWithDefender.READY_FOR_COURT,
})
diff --git a/apps/judicial-system/web/src/components/CourtArrangements/CourtArrangements.tsx b/apps/judicial-system/web/src/components/CourtArrangements/CourtArrangements.tsx
index 1f7ffd195205..563e676e692d 100644
--- a/apps/judicial-system/web/src/components/CourtArrangements/CourtArrangements.tsx
+++ b/apps/judicial-system/web/src/components/CourtArrangements/CourtArrangements.tsx
@@ -2,11 +2,16 @@ import React, { useEffect, useState } from 'react'
import compareAsc from 'date-fns/compareAsc'
import { Box, Input } from '@island.is/island-ui/core'
+import { getLatestDateType } from '@island.is/judicial-system/types'
import {
BlueBox,
DateTime,
} from '@island.is/judicial-system-web/src/components'
-import { NotificationType } from '@island.is/judicial-system-web/src/graphql/schema'
+import {
+ DateLog,
+ DateType,
+ NotificationType,
+} from '@island.is/judicial-system-web/src/graphql/schema'
import { TempCase as Case } from '@island.is/judicial-system-web/src/types'
import {
removeTabsValidateAndSet,
@@ -28,18 +33,23 @@ interface Props {
export const useCourtArrangements = (workingCase: Case) => {
const [courtDate, setCourtDate] = useState()
const [courtDateHasChanged, setCourtDateHasChanged] = useState(false)
+ const latestCourtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ ) as DateLog
useEffect(() => {
- if (workingCase.courtDate) {
- setCourtDate(workingCase.courtDate)
+ if (latestCourtDate) {
+ setCourtDate(latestCourtDate.date)
}
- }, [workingCase.courtDate])
+ }, [latestCourtDate])
const handleCourtDateChange = (date: Date | undefined, valid: boolean) => {
if (date && valid) {
if (
- workingCase.courtDate &&
- compareAsc(date, new Date(workingCase.courtDate)) !== 0 &&
+ latestCourtDate &&
+ latestCourtDate.date &&
+ compareAsc(date, new Date(latestCourtDate.date)) !== 0 &&
hasSentNotification(
NotificationType.COURT_DATE,
workingCase.notifications,
diff --git a/apps/judicial-system/web/src/components/FormProvider/case.graphql b/apps/judicial-system/web/src/components/FormProvider/case.graphql
index c6373a8712a0..76f6d16a5c67 100644
--- a/apps/judicial-system/web/src/components/FormProvider/case.graphql
+++ b/apps/judicial-system/web/src/components/FormProvider/case.graphql
@@ -69,7 +69,6 @@ query Case($input: CaseQueryInput!) {
}
courtCaseNumber
sessionArrangements
- courtDate
courtLocation
courtRoom
courtStartDate
@@ -240,6 +239,12 @@ query Case($input: CaseQueryInput!) {
nationalId
userRole
}
+ dateLogs {
+ id
+ caseId
+ dateType
+ date
+ }
prosecutorsOffice {
id
name
diff --git a/apps/judicial-system/web/src/components/FormProvider/limitedAccessCase.graphql b/apps/judicial-system/web/src/components/FormProvider/limitedAccessCase.graphql
index a2154d27bc1a..e439ff2f61cd 100644
--- a/apps/judicial-system/web/src/components/FormProvider/limitedAccessCase.graphql
+++ b/apps/judicial-system/web/src/components/FormProvider/limitedAccessCase.graphql
@@ -130,6 +130,12 @@ query LimitedAccessCase($input: CaseQueryInput!) {
id
name
}
+ dateLogs {
+ id
+ caseId
+ dateType
+ date
+ }
prosecutorsOffice {
id
name
diff --git a/apps/judicial-system/web/src/routes/Court/InvestigationCase/CourtRecord/CourtRecord.tsx b/apps/judicial-system/web/src/routes/Court/InvestigationCase/CourtRecord/CourtRecord.tsx
index a999a0f2198b..89cac17b33b4 100644
--- a/apps/judicial-system/web/src/routes/Court/InvestigationCase/CourtRecord/CourtRecord.tsx
+++ b/apps/judicial-system/web/src/routes/Court/InvestigationCase/CourtRecord/CourtRecord.tsx
@@ -12,6 +12,7 @@ import {
Tooltip,
} from '@island.is/island-ui/core'
import * as constants from '@island.is/judicial-system/consts'
+import { getLatestDateType } from '@island.is/judicial-system/types'
import {
closedCourt,
core,
@@ -33,6 +34,8 @@ import {
} from '@island.is/judicial-system-web/src/components'
import {
CaseType,
+ DateLog,
+ DateType,
SessionArrangements,
} from '@island.is/judicial-system-web/src/graphql/schema'
import { TempCase as Case } from '@island.is/judicial-system-web/src/types'
@@ -114,6 +117,10 @@ const CourtRecord = () => {
const initialize = useCallback(() => {
const autofillAttendees = []
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ ) as DateLog
if (workingCase.sessionArrangements === SessionArrangements.NONE_PRESENT) {
autofillAttendees.push(formatMessage(core.sessionArrangementsNonePresent))
@@ -157,10 +164,11 @@ const CourtRecord = () => {
}
}
}
+
setAndSendCaseToServer(
[
{
- courtStartDate: workingCase.courtDate,
+ courtStartDate: courtDate?.date,
courtLocation: workingCase.court?.name
? `í ${
workingCase.court.name.indexOf('dómur') > -1
diff --git a/apps/judicial-system/web/src/routes/Court/InvestigationCase/HearingArrangements/HearingArrangements.tsx b/apps/judicial-system/web/src/routes/Court/InvestigationCase/HearingArrangements/HearingArrangements.tsx
index b203dffb64f1..adde2d2becfa 100644
--- a/apps/judicial-system/web/src/routes/Court/InvestigationCase/HearingArrangements/HearingArrangements.tsx
+++ b/apps/judicial-system/web/src/routes/Court/InvestigationCase/HearingArrangements/HearingArrangements.tsx
@@ -4,6 +4,7 @@ import router from 'next/router'
import { AlertMessage, Box, RadioButton, Text } from '@island.is/island-ui/core'
import * as constants from '@island.is/judicial-system/consts'
+import { getLatestDateType } from '@island.is/judicial-system/types'
import { titles } from '@island.is/judicial-system-web/messages'
import {
BlueBox,
@@ -19,6 +20,7 @@ import {
useCourtArrangements,
} from '@island.is/judicial-system-web/src/components'
import {
+ DateType,
NotificationType,
SessionArrangements,
} from '@island.is/judicial-system-web/src/graphql/schema'
@@ -55,7 +57,12 @@ const HearingArrangements = () => {
const [checkedRadio, setCheckedRadio] = useState()
const initialize = useCallback(() => {
- if (!workingCase.courtDate) {
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ )
+
+ if (!courtDate) {
setCourtDate(workingCase.requestedCourtDate)
}
diff --git a/apps/judicial-system/web/src/routes/Court/InvestigationCase/Overview/Overview.tsx b/apps/judicial-system/web/src/routes/Court/InvestigationCase/Overview/Overview.tsx
index ad72e6a8f280..6969c140fe3a 100644
--- a/apps/judicial-system/web/src/routes/Court/InvestigationCase/Overview/Overview.tsx
+++ b/apps/judicial-system/web/src/routes/Court/InvestigationCase/Overview/Overview.tsx
@@ -16,6 +16,7 @@ import {
formatCaseType,
formatDate,
} from '@island.is/judicial-system/formatters'
+import { getLatestDateType } from '@island.is/judicial-system/types'
import {
core,
icCourtOverview,
@@ -40,7 +41,11 @@ import {
} from '@island.is/judicial-system-web/src/components'
import { NameAndEmail } from '@island.is/judicial-system-web/src/components/InfoCard/InfoCard'
import InfoCardCaseScheduled from '@island.is/judicial-system-web/src/components/InfoCard/InfoCardCaseScheduled'
-import { CaseState } from '@island.is/judicial-system-web/src/graphql/schema'
+import {
+ CaseState,
+ DateLog,
+ DateType,
+} from '@island.is/judicial-system-web/src/graphql/schema'
import {
UploadState,
useCourtUpload,
@@ -57,6 +62,11 @@ const Overview = () => {
const { uploadState } = useCourtUpload(workingCase, setWorkingCase)
const [isDraftingConclusion, setIsDraftingConclusion] = useState()
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ ) as DateLog
+
const handleNavigationTo = useCallback(
(destination: string) => router.push(`${destination}/${workingCase.id}`),
[workingCase.id],
@@ -105,12 +115,13 @@ const Overview = () => {
{workingCase.state === CaseState.RECEIVED &&
- workingCase.courtDate &&
+ courtDate &&
+ courtDate.date &&
workingCase.court && (
diff --git a/apps/judicial-system/web/src/routes/Court/InvestigationCase/Ruling/Ruling.tsx b/apps/judicial-system/web/src/routes/Court/InvestigationCase/Ruling/Ruling.tsx
index 6424d92c6b5c..831e22a149d2 100644
--- a/apps/judicial-system/web/src/routes/Court/InvestigationCase/Ruling/Ruling.tsx
+++ b/apps/judicial-system/web/src/routes/Court/InvestigationCase/Ruling/Ruling.tsx
@@ -12,7 +12,10 @@ import {
} from '@island.is/island-ui/core'
import * as constants from '@island.is/judicial-system/consts'
import { formatDate } from '@island.is/judicial-system/formatters'
-import { isAcceptingCaseDecision } from '@island.is/judicial-system/types'
+import {
+ getLatestDateType,
+ isAcceptingCaseDecision,
+} from '@island.is/judicial-system/types'
import { core, ruling, titles } from '@island.is/judicial-system-web/messages'
import {
CaseFileList,
@@ -27,7 +30,10 @@ import {
PoliceRequestAccordionItem,
RulingInput,
} from '@island.is/judicial-system-web/src/components'
-import { CaseDecision } from '@island.is/judicial-system-web/src/graphql/schema'
+import {
+ CaseDecision,
+ DateType,
+} from '@island.is/judicial-system-web/src/graphql/schema'
import {
removeTabsValidateAndSet,
validateAndSendToServer,
@@ -65,11 +71,16 @@ const Ruling = () => {
])
const initialize = useCallback(() => {
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ )
+
setAndSendCaseToServer(
[
{
introduction: formatMessage(m.sections.introduction.autofill, {
- date: formatDate(workingCase.courtDate, 'PPP'),
+ date: formatDate(courtDate?.date, 'PPP'),
}),
prosecutorDemands: workingCase.demands,
courtCaseFacts: formatMessage(
diff --git a/apps/judicial-system/web/src/routes/Court/RestrictionCase/CourtRecord/CourtRecord.tsx b/apps/judicial-system/web/src/routes/Court/RestrictionCase/CourtRecord/CourtRecord.tsx
index 1412cc662f29..b6c639859e7d 100644
--- a/apps/judicial-system/web/src/routes/Court/RestrictionCase/CourtRecord/CourtRecord.tsx
+++ b/apps/judicial-system/web/src/routes/Court/RestrictionCase/CourtRecord/CourtRecord.tsx
@@ -12,7 +12,10 @@ import {
Tooltip,
} from '@island.is/island-ui/core'
import * as constants from '@island.is/judicial-system/consts'
-import { isAcceptingCaseDecision } from '@island.is/judicial-system/types'
+import {
+ getLatestDateType,
+ isAcceptingCaseDecision,
+} from '@island.is/judicial-system/types'
import {
closedCourt,
core,
@@ -35,6 +38,8 @@ import {
import {
CaseDecision,
CaseType,
+ DateLog,
+ DateType,
} from '@island.is/judicial-system-web/src/graphql/schema'
import {
removeTabsValidateAndSet,
@@ -81,6 +86,10 @@ export const CourtRecord: React.FC> = () => {
const autofillAttendees = []
const autofillSessionBookings = []
const endOfSessionBookings = []
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ ) as DateLog
if (workingCase.courtAttendees !== '') {
if (workingCase.prosecutor) {
@@ -199,7 +208,7 @@ export const CourtRecord: React.FC> = () => {
setAndSendCaseToServer(
[
{
- courtStartDate: workingCase.courtDate,
+ courtStartDate: courtDate?.date,
courtLocation:
workingCase.court?.name &&
`í ${
diff --git a/apps/judicial-system/web/src/routes/Court/RestrictionCase/HearingArrangements/HearingArrangements.tsx b/apps/judicial-system/web/src/routes/Court/RestrictionCase/HearingArrangements/HearingArrangements.tsx
index 15562dfddb87..53a1c934c1df 100644
--- a/apps/judicial-system/web/src/routes/Court/RestrictionCase/HearingArrangements/HearingArrangements.tsx
+++ b/apps/judicial-system/web/src/routes/Court/RestrictionCase/HearingArrangements/HearingArrangements.tsx
@@ -4,6 +4,7 @@ import router from 'next/router'
import { AlertMessage, Box, Text } from '@island.is/island-ui/core'
import * as constants from '@island.is/judicial-system/consts'
+import { getLatestDateType } from '@island.is/judicial-system/types'
import { errors, titles } from '@island.is/judicial-system-web/messages'
import {
CourtArrangements,
@@ -20,6 +21,7 @@ import {
import {
CaseCustodyRestrictions,
CaseType,
+ DateType,
NotificationType,
} from '@island.is/judicial-system-web/src/graphql/schema'
import type { stepValidationsType } from '@island.is/judicial-system-web/src/utils/formHelper'
@@ -61,7 +63,12 @@ export const HearingArrangements: React.FC<
} = useCourtArrangements(workingCase)
const initialize = useCallback(() => {
- if (!workingCase.courtDate) {
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ )
+
+ if (!courtDate) {
setCourtDate(workingCase.requestedCourtDate)
}
diff --git a/apps/judicial-system/web/src/routes/Court/RestrictionCase/Overview/Overview.tsx b/apps/judicial-system/web/src/routes/Court/RestrictionCase/Overview/Overview.tsx
index df25a3b7452a..7208a46f5801 100644
--- a/apps/judicial-system/web/src/routes/Court/RestrictionCase/Overview/Overview.tsx
+++ b/apps/judicial-system/web/src/routes/Court/RestrictionCase/Overview/Overview.tsx
@@ -12,6 +12,7 @@ import {
} from '@island.is/island-ui/core'
import * as constants from '@island.is/judicial-system/consts'
import { capitalize, formatDate } from '@island.is/judicial-system/formatters'
+import { getLatestDateType } from '@island.is/judicial-system/types'
import {
core,
laws,
@@ -41,6 +42,8 @@ import InfoCardCaseScheduled from '@island.is/judicial-system-web/src/components
import {
CaseLegalProvisions,
CaseState,
+ DateLog,
+ DateType,
} from '@island.is/judicial-system-web/src/graphql/schema'
import {
UploadState,
@@ -57,6 +60,10 @@ export const JudgeOverview: React.FC> = () => {
const { formatMessage } = useIntl()
const router = useRouter()
const id = router.query.id
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ ) as DateLog
const { uploadState } = useCourtUpload(workingCase, setWorkingCase)
@@ -110,12 +117,13 @@ export const JudgeOverview: React.FC> = () => {
{workingCase.state === CaseState.RECEIVED &&
- workingCase.courtDate &&
+ courtDate &&
+ courtDate.date &&
workingCase.court && (
diff --git a/apps/judicial-system/web/src/routes/Court/RestrictionCase/Ruling/Ruling.tsx b/apps/judicial-system/web/src/routes/Court/RestrictionCase/Ruling/Ruling.tsx
index 3ff007108448..446991b2ee79 100644
--- a/apps/judicial-system/web/src/routes/Court/RestrictionCase/Ruling/Ruling.tsx
+++ b/apps/judicial-system/web/src/routes/Court/RestrictionCase/Ruling/Ruling.tsx
@@ -13,7 +13,10 @@ import {
} from '@island.is/island-ui/core'
import * as constants from '@island.is/judicial-system/consts'
import { formatDate, formatDOB } from '@island.is/judicial-system/formatters'
-import { isAcceptingCaseDecision } from '@island.is/judicial-system/types'
+import {
+ getLatestDateType,
+ isAcceptingCaseDecision,
+} from '@island.is/judicial-system/types'
import { core, ruling, titles } from '@island.is/judicial-system-web/messages'
import {
CaseFileList,
@@ -32,6 +35,7 @@ import {
import {
CaseDecision,
CaseType,
+ DateType,
Defendant,
} from '@island.is/judicial-system-web/src/graphql/schema'
import { TempCase as Case } from '@island.is/judicial-system-web/src/types'
@@ -138,11 +142,16 @@ export const Ruling: React.FC> = () => {
])
const initialize = useCallback(() => {
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ )
+
setAndSendCaseToServer(
[
{
introduction: formatMessage(m.sections.introduction.autofill, {
- date: formatDate(workingCase.courtDate, 'PPP'),
+ date: formatDate(courtDate?.date, 'PPP'),
}),
prosecutorDemands: workingCase.demands,
courtCaseFacts: formatMessage(
diff --git a/apps/judicial-system/web/src/routes/Defender/CaseOverview.tsx b/apps/judicial-system/web/src/routes/Defender/CaseOverview.tsx
index fb42f7d9494b..94f290e9d2ed 100644
--- a/apps/judicial-system/web/src/routes/Defender/CaseOverview.tsx
+++ b/apps/judicial-system/web/src/routes/Defender/CaseOverview.tsx
@@ -9,6 +9,7 @@ import {
formatCaseType,
} from '@island.is/judicial-system/formatters'
import {
+ getLatestDateType,
isCompletedCase,
isInvestigationCase,
isRestrictionCase,
@@ -34,6 +35,8 @@ import {
} from '@island.is/judicial-system-web/src/components'
import {
CaseState,
+ DateLog,
+ DateType,
RequestSharedWithDefender,
} from '@island.is/judicial-system-web/src/graphql/schema'
import { api } from '@island.is/judicial-system-web/src/services'
@@ -68,6 +71,11 @@ export const CaseOverview: React.FC> = () => {
isCompletedCase(workingCase.state) &&
(workingCase.canDefenderAppeal || workingCase.hasBeenAppealed)
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ ) as DateLog
+
return (
<>
{!isLoadingAppealBanner && shouldDisplayAlertBanner && (
@@ -130,12 +138,13 @@ export const CaseOverview: React.FC> = () => {
)}
{workingCase.state === CaseState.RECEIVED &&
- workingCase.courtDate &&
+ courtDate &&
+ courtDate.date &&
workingCase.court && (
diff --git a/apps/judicial-system/web/src/routes/Prosecutor/InvestigationCase/Overview/Overview.tsx b/apps/judicial-system/web/src/routes/Prosecutor/InvestigationCase/Overview/Overview.tsx
index 7d4daf069854..ac0e63d6148b 100644
--- a/apps/judicial-system/web/src/routes/Prosecutor/InvestigationCase/Overview/Overview.tsx
+++ b/apps/judicial-system/web/src/routes/Prosecutor/InvestigationCase/Overview/Overview.tsx
@@ -16,6 +16,7 @@ import {
formatCaseType,
formatDate,
} from '@island.is/judicial-system/formatters'
+import { getLatestDateType } from '@island.is/judicial-system/types'
import {
core,
errors,
@@ -45,6 +46,8 @@ import InfoCardCaseScheduled from '@island.is/judicial-system-web/src/components
import {
CaseState,
CaseTransition,
+ DateLog,
+ DateType,
NotificationType,
} from '@island.is/judicial-system-web/src/graphql/schema'
import { useCase } from '@island.is/judicial-system-web/src/utils/hooks'
@@ -113,6 +116,11 @@ export const Overview: React.FC> = () => {
const caseFiles =
workingCase.caseFiles?.filter((file) => !file.category) ?? []
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ ) as DateLog
+
return (
> = () => {
{workingCase.state === CaseState.RECEIVED &&
- workingCase.courtDate &&
+ courtDate &&
+ courtDate.date &&
workingCase.court && (
@@ -232,14 +241,14 @@ export const Overview: React.FC> = () => {
title: formatMessage(core.caseType),
value: capitalize(formatCaseType(workingCase.type)),
},
- ...(workingCase.courtDate
+ ...(courtDate && courtDate.date
? [
{
title: formatMessage(core.confirmedCourtDate),
value: `${capitalize(
- formatDate(workingCase.courtDate, 'PPPP', true) ?? '',
+ formatDate(courtDate.date, 'PPPP', true) ?? '',
)} kl. ${formatDate(
- workingCase.courtDate,
+ courtDate.date,
constants.TIME_FORMAT,
)}`,
},
diff --git a/apps/judicial-system/web/src/routes/Prosecutor/RestrictionCase/Overview/Overview.tsx b/apps/judicial-system/web/src/routes/Prosecutor/RestrictionCase/Overview/Overview.tsx
index a507dbeeb751..43e76e12c192 100644
--- a/apps/judicial-system/web/src/routes/Prosecutor/RestrictionCase/Overview/Overview.tsx
+++ b/apps/judicial-system/web/src/routes/Prosecutor/RestrictionCase/Overview/Overview.tsx
@@ -12,6 +12,7 @@ import {
} from '@island.is/island-ui/core'
import * as constants from '@island.is/judicial-system/consts'
import { capitalize, formatDate } from '@island.is/judicial-system/formatters'
+import { getLatestDateType } from '@island.is/judicial-system/types'
import {
core,
errors,
@@ -44,6 +45,8 @@ import {
CaseLegalProvisions,
CaseState,
CaseTransition,
+ DateLog,
+ DateType,
NotificationType,
} from '@island.is/judicial-system-web/src/graphql/schema'
import { useCase } from '@island.is/judicial-system-web/src/utils/hooks'
@@ -113,6 +116,11 @@ export const Overview: React.FC> = () => {
const caseFiles =
workingCase.caseFiles?.filter((file) => !file.category) ?? []
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ ) as DateLog
+
return (
> = () => {
{workingCase.state === CaseState.RECEIVED &&
- workingCase.courtDate &&
+ courtDate &&
+ courtDate.date &&
workingCase.court && (
@@ -257,14 +266,14 @@ export const Overview: React.FC> = () => {
)}`
: 'Var ekki skráður',
},
- ...(workingCase.courtDate
+ ...(courtDate
? [
{
title: formatMessage(core.confirmedCourtDate),
value: `${capitalize(
- formatDate(workingCase.courtDate, 'PPPP', true) ?? '',
+ formatDate(courtDate.date, 'PPPP', true) ?? '',
)} kl. ${formatDate(
- workingCase.courtDate,
+ courtDate.date,
constants.TIME_FORMAT,
)}`,
},
diff --git a/apps/judicial-system/web/src/routes/Prosecutor/components/RequestCourtDate/RequestCourtDate.tsx b/apps/judicial-system/web/src/routes/Prosecutor/components/RequestCourtDate/RequestCourtDate.tsx
index 7647dc709ad0..cd62c8076744 100644
--- a/apps/judicial-system/web/src/routes/Prosecutor/components/RequestCourtDate/RequestCourtDate.tsx
+++ b/apps/judicial-system/web/src/routes/Prosecutor/components/RequestCourtDate/RequestCourtDate.tsx
@@ -2,8 +2,10 @@ import React from 'react'
import { useIntl } from 'react-intl'
import { Box, Text, Tooltip } from '@island.is/island-ui/core'
+import { getLatestDateType } from '@island.is/judicial-system/types'
import { requestCourtDate as m } from '@island.is/judicial-system-web/messages'
import { DateTime } from '@island.is/judicial-system-web/src/components'
+import { DateType } from '@island.is/judicial-system-web/src/graphql/schema'
import { TempCase as Case } from '@island.is/judicial-system-web/src/types'
interface Props {
@@ -14,6 +16,7 @@ interface Props {
const RequestCourtDate: React.FC> = (props) => {
const { workingCase, onChange } = props
const { formatMessage } = useIntl()
+ const courtDate = getLatestDateType(DateType.COURT_DATE, workingCase.dateLogs)
return (
<>
@@ -30,11 +33,11 @@ const RequestCourtDate: React.FC> = (props) => {
selectedDate={workingCase.requestedCourtDate}
onChange={onChange}
timeLabel={formatMessage(m.dateInput.timeLabel)}
- locked={Boolean(workingCase.courtDate)}
+ locked={Boolean(courtDate?.date)}
minDate={new Date()}
required
/>
- {workingCase.courtDate && (
+ {courtDate && (
{formatMessage(m.courtDate)}
diff --git a/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealCaseFiles.spec.tsx b/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealCaseFiles.spec.tsx
index 2a5ab3e15e17..d73299cc73c2 100644
--- a/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealCaseFiles.spec.tsx
+++ b/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealCaseFiles.spec.tsx
@@ -1,11 +1,12 @@
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'
import { render, screen } from '@testing-library/react'
-import { CaseState, UserRole } from '@island.is/judicial-system/types'
import {
CaseAppealRulingDecision,
CaseDecision,
+ CaseState,
CaseType,
+ UserRole,
} from '@island.is/judicial-system-web/src/graphql/schema'
import { mockCase } from '@island.is/judicial-system-web/src/utils/mocks'
import {
diff --git a/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealCaseFiles.tsx b/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealCaseFiles.tsx
index 76b680327891..e6417026475d 100644
--- a/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealCaseFiles.tsx
+++ b/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealCaseFiles.tsx
@@ -12,12 +12,8 @@ import {
import * as constants from '@island.is/judicial-system/consts'
import { formatDate } from '@island.is/judicial-system/formatters'
import {
- CaseAppealDecision,
- CaseFileCategory,
isDefenceUser,
isProsecutionUser,
- NotificationType,
- UserRole,
} from '@island.is/judicial-system/types'
import { core, titles } from '@island.is/judicial-system-web/messages'
import {
@@ -32,6 +28,12 @@ import {
} from '@island.is/judicial-system-web/src/components'
import RequestAppealRulingNotToBePublishedCheckbox from '@island.is/judicial-system-web/src/components/RequestAppealRulingNotToBePublishedCheckbox/RequestAppealRulingNotToBePublishedCheckbox'
import RulingDateLabel from '@island.is/judicial-system-web/src/components/RulingDateLabel/RulingDateLabel'
+import {
+ CaseAppealDecision,
+ CaseFileCategory,
+ NotificationType,
+ UserRole,
+} from '@island.is/judicial-system-web/src/graphql/schema'
import {
useCase,
useS3Upload,
diff --git a/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealToCourtOfAppeals.spec.tsx b/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealToCourtOfAppeals.spec.tsx
index ee2f5017f3d0..0d1eeae01f07 100644
--- a/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealToCourtOfAppeals.spec.tsx
+++ b/apps/judicial-system/web/src/routes/Shared/AppealToCourtOfAppeals/AppealToCourtOfAppeals.spec.tsx
@@ -1,11 +1,12 @@
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'
import { render, screen } from '@testing-library/react'
-import { CaseState, UserRole } from '@island.is/judicial-system/types'
import {
CaseAppealRulingDecision,
CaseDecision,
+ CaseState,
CaseType,
+ UserRole,
} from '@island.is/judicial-system-web/src/graphql/schema'
import { mockCase } from '@island.is/judicial-system-web/src/utils/mocks'
import {
diff --git a/apps/judicial-system/web/src/routes/Shared/Cases/ActiveCases.tsx b/apps/judicial-system/web/src/routes/Shared/Cases/ActiveCases.tsx
index 749dabe80c2e..ba3a38a93bfe 100644
--- a/apps/judicial-system/web/src/routes/Shared/Cases/ActiveCases.tsx
+++ b/apps/judicial-system/web/src/routes/Shared/Cases/ActiveCases.tsx
@@ -14,7 +14,6 @@ import {
formatDOB,
} from '@island.is/judicial-system/formatters'
import {
- CaseState,
isDistrictCourtUser,
isProsecutionUser,
} from '@island.is/judicial-system/types'
@@ -33,7 +32,10 @@ import {
SortButton,
} from '@island.is/judicial-system-web/src/components/Table'
import { table as tableStrings } from '@island.is/judicial-system-web/src/components/Table/Table.strings'
-import { CaseListEntry } from '@island.is/judicial-system-web/src/graphql/schema'
+import {
+ CaseListEntry,
+ CaseState,
+} from '@island.is/judicial-system-web/src/graphql/schema'
import {
directionType,
sortableTableColumn,
diff --git a/apps/judicial-system/web/src/routes/Shared/IndictmentOverview/IndictmentOverview.tsx b/apps/judicial-system/web/src/routes/Shared/IndictmentOverview/IndictmentOverview.tsx
index 148aa5305f85..254097e413d6 100644
--- a/apps/judicial-system/web/src/routes/Shared/IndictmentOverview/IndictmentOverview.tsx
+++ b/apps/judicial-system/web/src/routes/Shared/IndictmentOverview/IndictmentOverview.tsx
@@ -5,6 +5,7 @@ import { useRouter } from 'next/router'
import { Box } from '@island.is/island-ui/core'
import * as constants from '@island.is/judicial-system/consts'
import {
+ getLatestDateType,
isCompletedCase,
isDefenceUser,
isDistrictCourtUser,
@@ -26,7 +27,11 @@ import {
UserContext,
} from '@island.is/judicial-system-web/src/components'
import InfoCardCaseScheduled from '@island.is/judicial-system-web/src/components/InfoCard/InfoCardCaseScheduled'
-import { CaseState } from '@island.is/judicial-system-web/src/graphql/schema'
+import {
+ CaseState,
+ DateLog,
+ DateType,
+} from '@island.is/judicial-system-web/src/graphql/schema'
import ReturnIndictmentModal from '../../Court/Indictments/ReturnIndictmentCaseModal/ReturnIndictmentCaseModal'
import { strings } from './IndictmentOverview.strings'
@@ -47,6 +52,11 @@ const IndictmentOverview = () => {
[router, workingCase.id],
)
+ const courtDate = getLatestDateType(
+ DateType.COURT_DATE,
+ workingCase.dateLogs,
+ ) as DateLog
+
return (
{
{workingCase.state === CaseState.RECEIVED &&
- workingCase.courtDate &&
+ courtDate &&
+ courtDate.date &&
workingCase.court && (
diff --git a/apps/judicial-system/web/src/routes/Shared/Statement/Statement.spec.tsx b/apps/judicial-system/web/src/routes/Shared/Statement/Statement.spec.tsx
index bbb019caae4c..6dff02bd0b49 100644
--- a/apps/judicial-system/web/src/routes/Shared/Statement/Statement.spec.tsx
+++ b/apps/judicial-system/web/src/routes/Shared/Statement/Statement.spec.tsx
@@ -1,11 +1,12 @@
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'
import { render, screen } from '@testing-library/react'
-import { CaseState, UserRole } from '@island.is/judicial-system/types'
import {
CaseAppealRulingDecision,
CaseDecision,
+ CaseState,
CaseType,
+ UserRole,
} from '@island.is/judicial-system-web/src/graphql/schema'
import { mockCase } from '@island.is/judicial-system-web/src/utils/mocks'
import {
diff --git a/apps/judicial-system/web/src/utils/hooks/useCase/index.ts b/apps/judicial-system/web/src/utils/hooks/useCase/index.ts
index 54f219ea9e40..fc8a4419ee86 100644
--- a/apps/judicial-system/web/src/utils/hooks/useCase/index.ts
+++ b/apps/judicial-system/web/src/utils/hooks/useCase/index.ts
@@ -87,7 +87,7 @@ const overwrite = (update: UpdateCase): UpdateCase => {
export const fieldHasValue =
(workingCase: Case) => (value: unknown, key: string) => {
- const theKey = key as keyof UpdateCaseInput // loadash types are not better than this
+ const theKey = key as keyof Omit // loadash types are not better than this
if (
isChildKey(theKey) // check if key is f.example `judgeId`
diff --git a/apps/judicial-system/web/src/utils/hooks/useCase/updateCase.graphql b/apps/judicial-system/web/src/utils/hooks/useCase/updateCase.graphql
index 005c4f2ad6f9..bb77596b54fb 100644
--- a/apps/judicial-system/web/src/utils/hooks/useCase/updateCase.graphql
+++ b/apps/judicial-system/web/src/utils/hooks/useCase/updateCase.graphql
@@ -68,7 +68,6 @@ mutation UpdateCase($input: UpdateCaseInput!) {
}
courtCaseNumber
sessionArrangements
- courtDate
courtLocation
courtRoom
courtStartDate
diff --git a/apps/judicial-system/web/src/utils/hooks/useSections/index.ts b/apps/judicial-system/web/src/utils/hooks/useSections/index.ts
index a4e0caf2c7ef..388d9de12049 100644
--- a/apps/judicial-system/web/src/utils/hooks/useSections/index.ts
+++ b/apps/judicial-system/web/src/utils/hooks/useSections/index.ts
@@ -900,93 +900,97 @@ const useSections = (
isActive:
(isDistrictCourtUser(user) || isDefenceUser(user)) &&
!isCompletedCase(state),
- children: [
- {
- name: formatMessage(sections.indictmentsCourtSection.overview),
- isActive: isDefenceUser(user)
- ? false
- : isActive(constants.INDICTMENTS_COURT_OVERVIEW_ROUTE),
- href: `${constants.INDICTMENTS_COURT_OVERVIEW_ROUTE}/${id}`,
- },
- {
- name: formatMessage(
- sections.indictmentsCourtSection.receptionAndAssignment,
- ),
- isActive: isActive(
- constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
- ),
- href: `${constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE}/${id}`,
- onClick:
- !isActive(constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE) &&
- validateFormStepper(isValid, [], workingCase) &&
- onNavigationTo
- ? async () =>
- await onNavigationTo(
- constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
- )
- : undefined,
- },
- {
- name: formatMessage(sections.indictmentsCourtSection.subpoena),
- isActive: isActive(constants.INDICTMENTS_SUBPOENA_ROUTE),
- href: `${constants.INDICTMENTS_SUBPOENA_ROUTE}/${id}`,
- onClick:
- !isActive(constants.INDICTMENTS_SUBPOENA_ROUTE) &&
- validateFormStepper(
- isValid,
- [
- constants.INDICTMENTS_OVERVIEW_ROUTE,
- constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
- ],
- workingCase,
- ) &&
- onNavigationTo
- ? async () =>
- await onNavigationTo(constants.INDICTMENTS_SUBPOENA_ROUTE)
- : undefined,
- },
- {
- name: formatMessage(sections.indictmentsCourtSection.defender),
- isActive: isActive(constants.INDICTMENTS_DEFENDER_ROUTE),
- href: `${constants.INDICTMENTS_DEFENDER_ROUTE}/${id}`,
- onClick:
- !isActive(constants.INDICTMENTS_DEFENDER_ROUTE) &&
- validateFormStepper(
- isValid,
- [
- constants.INDICTMENTS_OVERVIEW_ROUTE,
- constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
- constants.INDICTMENTS_SUBPOENA_ROUTE,
- ],
- workingCase,
- ) &&
- onNavigationTo
- ? async () =>
- await onNavigationTo(constants.INDICTMENTS_DEFENDER_ROUTE)
- : undefined,
- },
- {
- name: formatMessage(sections.indictmentsCourtSection.courtRecord),
- isActive: isActive(constants.INDICTMENTS_COURT_RECORD_ROUTE),
- href: `${constants.INDICTMENTS_COURT_RECORD_ROUTE}/${id}`,
- onClick:
- !isActive(constants.INDICTMENTS_COURT_RECORD_ROUTE) &&
- validateFormStepper(
- isValid,
- [
- constants.INDICTMENTS_OVERVIEW_ROUTE,
+ children: isDistrictCourtUser(user)
+ ? [
+ {
+ name: formatMessage(sections.indictmentsCourtSection.overview),
+ isActive: isActive(constants.INDICTMENTS_COURT_OVERVIEW_ROUTE),
+ href: `${constants.INDICTMENTS_COURT_OVERVIEW_ROUTE}/${id}`,
+ },
+ {
+ name: formatMessage(
+ sections.indictmentsCourtSection.receptionAndAssignment,
+ ),
+ isActive: isActive(
constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
- constants.INDICTMENTS_SUBPOENA_ROUTE,
- constants.INDICTMENTS_DEFENDER_ROUTE,
- ],
- workingCase,
- ) &&
- onNavigationTo
- ? async () =>
- await onNavigationTo(constants.INDICTMENTS_COURT_RECORD_ROUTE)
- : undefined,
- },
- ],
+ ),
+ href: `${constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE}/${id}`,
+ onClick:
+ !isActive(
+ constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
+ ) &&
+ validateFormStepper(isValid, [], workingCase) &&
+ onNavigationTo
+ ? async () =>
+ await onNavigationTo(
+ constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
+ )
+ : undefined,
+ },
+ {
+ name: formatMessage(sections.indictmentsCourtSection.subpoena),
+ isActive: isActive(constants.INDICTMENTS_SUBPOENA_ROUTE),
+ href: `${constants.INDICTMENTS_SUBPOENA_ROUTE}/${id}`,
+ onClick:
+ !isActive(constants.INDICTMENTS_SUBPOENA_ROUTE) &&
+ validateFormStepper(
+ isValid,
+ [
+ constants.INDICTMENTS_OVERVIEW_ROUTE,
+ constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
+ ],
+ workingCase,
+ ) &&
+ onNavigationTo
+ ? async () =>
+ await onNavigationTo(constants.INDICTMENTS_SUBPOENA_ROUTE)
+ : undefined,
+ },
+ {
+ name: formatMessage(sections.indictmentsCourtSection.defender),
+ isActive: isActive(constants.INDICTMENTS_DEFENDER_ROUTE),
+ href: `${constants.INDICTMENTS_DEFENDER_ROUTE}/${id}`,
+ onClick:
+ !isActive(constants.INDICTMENTS_DEFENDER_ROUTE) &&
+ validateFormStepper(
+ isValid,
+ [
+ constants.INDICTMENTS_OVERVIEW_ROUTE,
+ constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
+ constants.INDICTMENTS_SUBPOENA_ROUTE,
+ ],
+ workingCase,
+ ) &&
+ onNavigationTo
+ ? async () =>
+ await onNavigationTo(constants.INDICTMENTS_DEFENDER_ROUTE)
+ : undefined,
+ },
+ {
+ name: formatMessage(sections.indictmentsCourtSection.courtRecord),
+ isActive: isActive(constants.INDICTMENTS_COURT_RECORD_ROUTE),
+ href: `${constants.INDICTMENTS_COURT_RECORD_ROUTE}/${id}`,
+ onClick:
+ !isActive(constants.INDICTMENTS_COURT_RECORD_ROUTE) &&
+ validateFormStepper(
+ isValid,
+ [
+ constants.INDICTMENTS_OVERVIEW_ROUTE,
+ constants.INDICTMENTS_RECEPTION_AND_ASSIGNMENT_ROUTE,
+ constants.INDICTMENTS_SUBPOENA_ROUTE,
+ constants.INDICTMENTS_DEFENDER_ROUTE,
+ ],
+ workingCase,
+ ) &&
+ onNavigationTo
+ ? async () =>
+ await onNavigationTo(
+ constants.INDICTMENTS_COURT_RECORD_ROUTE,
+ )
+ : undefined,
+ },
+ ]
+ : [],
}
}
diff --git a/apps/judicial-system/web/src/utils/validate.ts b/apps/judicial-system/web/src/utils/validate.ts
index fe8b8e10f7a9..0b57e7c7bd5d 100644
--- a/apps/judicial-system/web/src/utils/validate.ts
+++ b/apps/judicial-system/web/src/utils/validate.ts
@@ -331,12 +331,10 @@ export const isCourtHearingArrangemenstStepValidRC = (
workingCase: Case,
courtDate?: string | null,
): boolean => {
- const date = courtDate || workingCase.courtDate
-
return validate([
[workingCase.defenderEmail, ['email-format']],
[workingCase.defenderPhoneNumber, ['phonenumber']],
- [date, ['empty', 'date-format']],
+ [courtDate, ['empty', 'date-format']],
]).isValid
}
@@ -344,14 +342,12 @@ export const isCourtHearingArrangementsStepValidIC = (
workingCase: Case,
courtDate?: string | null,
): boolean => {
- const date = courtDate || workingCase.courtDate
-
return Boolean(
workingCase.sessionArrangements &&
validate([
[workingCase.defenderEmail, ['email-format']],
[workingCase.defenderPhoneNumber, ['phonenumber']],
- [date, ['empty', 'date-format']],
+ [courtDate, ['empty', 'date-format']],
]).isValid,
)
}
@@ -413,9 +409,7 @@ export const isSubpoenaStepValid = (
workingCase: Case,
courtDate?: string | null,
): boolean => {
- const date = courtDate || workingCase.courtDate
-
- return validate([[date, ['empty', 'date-format']]]).isValid
+ return validate([[courtDate, ['empty', 'date-format']]]).isValid
}
export const isDefenderStepValid = (workingCase: Case): boolean => {
diff --git a/apps/native/app/android/app/build.gradle b/apps/native/app/android/app/build.gradle
index bfd8de697746..fe2f0a97b2a6 100644
--- a/apps/native/app/android/app/build.gradle
+++ b/apps/native/app/android/app/build.gradle
@@ -113,7 +113,7 @@ android {
applicationId "is.island.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 142
+ versionCode 143
versionName "1.2.6"
manifestPlaceholders = [
appAuthRedirectScheme: "is.island.app" // project.config.get("BUNDLE_ID_ANDROID")
diff --git a/apps/native/app/ios/IslandApp.xcodeproj/project.pbxproj b/apps/native/app/ios/IslandApp.xcodeproj/project.pbxproj
index 3df28de4ee40..7fe22336e113 100644
--- a/apps/native/app/ios/IslandApp.xcodeproj/project.pbxproj
+++ b/apps/native/app/ios/IslandApp.xcodeproj/project.pbxproj
@@ -431,7 +431,7 @@
CODE_SIGN_ENTITLEMENTS = IslandApp/IslandApp.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 142;
+ CURRENT_PROJECT_VERSION = 144;
DEVELOPMENT_TEAM = J3WWZR9JLF;
DISPLAY_NAME = "Ísland.dev";
INFOPLIST_FILE = IslandApp/Info.plist;
@@ -603,7 +603,7 @@
CODE_SIGN_ENTITLEMENTS = IslandApp/IslandApp.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 142;
+ CURRENT_PROJECT_VERSION = 144;
DEVELOPMENT_TEAM = J3WWZR9JLF;
DISPLAY_NAME = "Ísland.is";
INFOPLIST_FILE = IslandApp/Info.plist;
@@ -716,7 +716,7 @@
CODE_SIGN_ENTITLEMENTS = IslandApp/IslandApp.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 142;
+ CURRENT_PROJECT_VERSION = 144;
DEVELOPMENT_TEAM = J3WWZR9JLF;
DISPLAY_NAME = "Ísland.dev";
ENABLE_BITCODE = NO;
diff --git a/apps/native/app/ios/IslandApp/Info.plist b/apps/native/app/ios/IslandApp/Info.plist
index 45e789555bc0..844ab4e5baf2 100644
--- a/apps/native/app/ios/IslandApp/Info.plist
+++ b/apps/native/app/ios/IslandApp/Info.plist
@@ -34,7 +34,7 @@
CFBundleVersion
- 142
+ 144
LSRequiresIPhoneOS
NSAppTransportSecurity
diff --git a/apps/native/app/ios/IslandApp/IslandApp.entitlements b/apps/native/app/ios/IslandApp/IslandApp.entitlements
index cdb1d09e89c5..710049617b26 100644
--- a/apps/native/app/ios/IslandApp/IslandApp.entitlements
+++ b/apps/native/app/ios/IslandApp/IslandApp.entitlements
@@ -4,6 +4,10 @@
aps-environment
development
+ com.apple.developer.associated-domains
+
+ webcredentials:island.is
+
keychain-access-groups
$(AppIdentifierPrefix)is.island.app
diff --git a/apps/native/app/src/screens/settings/edit-confirm.tsx b/apps/native/app/src/screens/settings/edit-confirm.tsx
index a0cbb0966a11..a7e3c22a3527 100644
--- a/apps/native/app/src/screens/settings/edit-confirm.tsx
+++ b/apps/native/app/src/screens/settings/edit-confirm.tsx
@@ -34,7 +34,7 @@ export const EditConfirmScreen: NavigationFunctionComponent = ({
const intl = useIntl()
const [text, onChangeText] = React.useState('')
const { updateUserProfile } = useUpdateUserProfile()
- const disabled = text?.trim().length < 6
+ const disabled = text?.trim().length < 3
const [loading, setLoading] = React.useState(false)
const handleConfirm = async () => {
@@ -84,8 +84,8 @@ export const EditConfirmScreen: NavigationFunctionComponent = ({
label={intl.formatMessage({ id: 'edit.confirm.inputlabel' })}
value={text}
onChange={onChangeText}
- placeholder="000000"
- maxLength={6}
+ placeholder="000"
+ maxLength={3}
autoFocus
keyboardType="number-pad"
textContentType="oneTimeCode"
diff --git a/apps/service-portal/src/auth.ts b/apps/service-portal/src/auth.ts
index 7b5394d51566..dc6d5b12c6c7 100644
--- a/apps/service-portal/src/auth.ts
+++ b/apps/service-portal/src/auth.ts
@@ -5,6 +5,7 @@ import {
AuthScope,
DocumentsScope,
EndorsementsScope,
+ HmsScope,
NationalRegistryScope,
NotificationsScope,
UserProfileScope,
diff --git a/apps/service-portal/src/components/Header/Header.css.ts b/apps/service-portal/src/components/Header/Header.css.ts
index 31a25b7e758f..26bd5a447b7f 100644
--- a/apps/service-portal/src/components/Header/Header.css.ts
+++ b/apps/service-portal/src/components/Header/Header.css.ts
@@ -1,4 +1,4 @@
-import { style, styleVariants } from '@vanilla-extract/css'
+import { globalStyle, style, styleVariants } from '@vanilla-extract/css'
import {
SERVICE_PORTAL_HEADER_HEIGHT_LG,
SERVICE_PORTAL_HEADER_HEIGHT_SM,
@@ -40,22 +40,12 @@ export const closeButton = style({
},
})
-export const badge = styleVariants({
- active: {
- position: 'absolute',
- top: 10,
- right: 13,
- height: theme.spacing[1],
- width: theme.spacing[1],
- borderRadius: '50%',
- backgroundColor: theme.color.red400,
- ...themeUtils.responsiveStyle({
- md: {
- top: 14,
- },
- }),
- },
- inactive: {
- display: 'none',
+export const overview = style({})
+
+globalStyle(`${overview} svg`, {
+ '@media': {
+ [`screen and (max-width: ${theme.breakpoints.sm - 1}px)`]: {
+ marginLeft: '0 !important',
+ },
},
})
diff --git a/apps/service-portal/src/components/Header/Header.tsx b/apps/service-portal/src/components/Header/Header.tsx
index b167f4c58209..a6c0a62ac94e 100644
--- a/apps/service-portal/src/components/Header/Header.tsx
+++ b/apps/service-portal/src/components/Header/Header.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import {
Box,
Hidden,
@@ -15,35 +15,44 @@ import { useLocale } from '@island.is/localization'
import { UserLanguageSwitcher, UserMenu } from '@island.is/shared/components'
import { m } from '@island.is/service-portal/core'
import { Link } from 'react-router-dom'
-import { useListDocuments } from '@island.is/service-portal/graphql'
-import cn from 'classnames'
-import { theme } from '@island.is/island-ui/theme'
+import { helperStyles, theme } from '@island.is/island-ui/theme'
import { useWindowSize } from 'react-use'
import { PortalPageLoader } from '@island.is/portals/core'
import { useAuth } from '@island.is/auth/react'
import Sidemenu from '../Sidemenu/Sidemenu'
import { DocumentsPaths } from '@island.is/service-portal/documents'
+import NotificationButton from '../Notifications/NotificationButton'
+import { useFeatureFlagClient } from '@island.is/react/feature-flags'
+export type MenuTypes = 'side' | 'user' | 'notifications' | undefined
interface Props {
position: number
- sideMenuOpen: boolean
- setSideMenuOpen: (set: boolean) => void
}
-export const Header = ({ position, sideMenuOpen, setSideMenuOpen }: Props) => {
+export const Header = ({ position }: Props) => {
const { formatMessage } = useLocale()
- const [userMenuOpen, setUserMenuOpen] = useState(false)
- const { unreadCounter } = useListDocuments()
+ const [menuOpen, setMenuOpen] = useState()
const { width } = useWindowSize()
const ref = useRef(null)
const isMobile = width < theme.breakpoints.md
const { userInfo: user } = useAuth()
- const badgeActive: keyof typeof styles.badge =
- unreadCounter > 0 ? 'active' : 'inactive'
- const closeUserMenu = (state: boolean) => {
- setSideMenuOpen(false)
- setUserMenuOpen(state)
- }
+ // Notification feature flag. Remove after feature is live.
+ const [enableNotificationFlag, setEnableNotificationFlag] =
+ useState(false)
+ const featureFlagClient = useFeatureFlagClient()
+ useEffect(() => {
+ const isFlagEnabled = async () => {
+ const ffEnabled = await featureFlagClient.getValue(
+ `isServicePortalNotificationsPageEnabled`,
+ false,
+ )
+ if (ffEnabled) {
+ setEnableNotificationFlag(ffEnabled as boolean)
+ }
+ }
+ isFlagEnabled()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
return (
@@ -84,6 +93,7 @@ export const Header = ({ position, sideMenuOpen, setSideMenuOpen }: Props) => {
flexWrap="nowrap"
marginLeft={[1, 1, 2]}
>
+ {user &&
}
@@ -95,50 +105,64 @@ export const Header = ({ position, sideMenuOpen, setSideMenuOpen }: Props) => {
iconType="outline"
type="span"
unfocusable
- >
- {!isMobile && formatMessage(m.documents)}
-
+ />
+
+
+ {formatMessage(m.documents)}
+
-
- {user &&
}
- {/* Display X icon instead of dots if open in mobile*/}
+ {enableNotificationFlag && (
+
setMenuOpen(val)}
+ showMenu={menuOpen === 'notifications'}
+ />
+ )}
-
+
{
- sideMenuOpen && isMobile
- ? setSideMenuOpen(false)
- : setSideMenuOpen(true)
- setUserMenuOpen(false)
+ menuOpen === 'side' && isMobile
+ ? setMenuOpen(undefined)
+ : setMenuOpen('side')
}}
ref={ref}
>
- {formatMessage(m.overview)}
+
+ {formatMessage(m.overview)}
+
setSideMenuOpen(set)}
- sideMenuOpen={sideMenuOpen}
+ setSideMenuOpen={(set: boolean) =>
+ setMenuOpen(set ? 'side' : undefined)
+ }
+ sideMenuOpen={menuOpen === 'side'}
rightPosition={
ref.current?.getBoundingClientRect().right
}
/>
- {/* Display X button instead if open in mobile*/}
+ setMenuOpen(
+ set
+ ? 'user'
+ : menuOpen === 'user'
+ ? undefined
+ : menuOpen,
+ )
+ }
showLanguageSwitcher={false}
- userMenuOpen={userMenuOpen}
+ userMenuOpen={menuOpen === 'user'}
/>
diff --git a/apps/service-portal/src/components/Layout/Layout.tsx b/apps/service-portal/src/components/Layout/Layout.tsx
index 92af2990ecbe..7a206e81bc33 100644
--- a/apps/service-portal/src/components/Layout/Layout.tsx
+++ b/apps/service-portal/src/components/Layout/Layout.tsx
@@ -17,7 +17,6 @@ export const Layout: FC> = ({ children }) => {
useNamespaces(['service.portal', 'global', 'portals'])
const activeModule = useActiveModule()
const { pathname } = useLocation()
- const [sideMenuOpen, setSideMenuOpen] = useState(false)
const navigation = useDynamicRoutesWithNavigation(MAIN_NAVIGATION)
const activeParent = navigation?.children?.find((item) => {
@@ -39,11 +38,7 @@ export const Layout: FC> = ({ children }) => {
{globalBanners.length > 0 && (
)}
- setSideMenuOpen(set)}
- sideMenuOpen={sideMenuOpen}
- position={height && globalBanners.length > 0 ? height : 0}
- />
+ 0 ? height : 0} />
{!isFullwidth && activeParent && (
void
+ showMenu?: boolean
+}
+
+const NotificationButton = ({ setMenuState, showMenu = false }: Props) => {
+ const { formatMessage } = useLocale()
+ const [hasMarkedLocally, setHasMarkedLocally] = useState(false)
+ const [markAllAsSeen] = useMarkAllNotificationsAsSeenMutation()
+ const { width } = useWindowSize()
+ const isMobile = width < theme.breakpoints.md
+ const ref = useRef(null)
+
+ const { data } = useGetUserNotificationsOverviewQuery({
+ variables: {
+ input: {
+ limit: 5,
+ },
+ },
+ })
+
+ const showBadge =
+ !!data?.userNotificationsOverview?.unseenCount && !hasMarkedLocally
+
+ useEffect(() => {
+ if (showMenu && showBadge) {
+ markAllAsSeen()
+ setHasMarkedLocally(true)
+ }
+ }, [showMenu, showBadge])
+
+ return (
+
+ {
+ showMenu && isMobile
+ ? setMenuState(undefined)
+ : setMenuState('notifications')
+ }}
+ ref={ref}
+ aria-label={formatMessage(m.notifications)}
+ />
+ {data?.userNotificationsOverview?.data.length ? (
+
+ ) : undefined}
+ setMenuState(undefined)}
+ sideMenuOpen={showMenu}
+ rightPosition={ref.current?.getBoundingClientRect().right}
+ data={data}
+ />
+
+ )
+}
+
+export default NotificationButton
diff --git a/apps/service-portal/src/components/Notifications/NotificationLine.tsx b/apps/service-portal/src/components/Notifications/NotificationLine.tsx
new file mode 100644
index 000000000000..2e8a14b5aa80
--- /dev/null
+++ b/apps/service-portal/src/components/Notifications/NotificationLine.tsx
@@ -0,0 +1,93 @@
+import { FC, useRef } from 'react'
+import { Box, Text } from '@island.is/island-ui/core'
+import { dateFormat } from '@island.is/shared/constants'
+import { LinkResolver } from '@island.is/service-portal/core'
+import format from 'date-fns/format'
+import cn from 'classnames'
+import * as styles from './Notifications.css'
+import {
+ NotificationMessage,
+ NotificationMetadata,
+ NotificationSender,
+} from '@island.is/api/schema'
+import { AvatarImage } from '@island.is/service-portal/documents'
+import { resolveLink } from '@island.is/service-portal/information'
+
+interface Props {
+ data: {
+ metadata: NotificationMetadata
+ message: Omit
+ sender: NotificationSender
+ }
+ onClickCallback: () => void
+}
+
+export const NotificationLine = ({ data, onClickCallback }: Props) => {
+ const date = data.metadata?.created
+ ? format(new Date(data.metadata.created), dateFormat.is)
+ : ''
+
+ const isRead = data.metadata?.read
+
+ return (
+
+
+
+ {data.sender?.logoUrl ? (
+
+ ) : undefined}
+
+
+
+ {data.message.title}
+
+ {date}
+
+
+ {data.message.displayBody}
+
+
+
+
+
+ )
+}
+
+export default NotificationLine
diff --git a/apps/service-portal/src/components/Notifications/NotificationMenu.tsx b/apps/service-portal/src/components/Notifications/NotificationMenu.tsx
new file mode 100644
index 000000000000..d320c0aa81f9
--- /dev/null
+++ b/apps/service-portal/src/components/Notifications/NotificationMenu.tsx
@@ -0,0 +1,172 @@
+import React, { ReactElement } from 'react'
+import {
+ Box,
+ Button,
+ GridContainer,
+ Hidden,
+ Icon,
+ ModalBase,
+ Text,
+} from '@island.is/island-ui/core'
+import { LinkResolver } from '@island.is/service-portal/core'
+import {
+ GetUserNotificationsOverviewQuery,
+ InformationPaths,
+} from '@island.is/service-portal/information'
+import { sharedMessages } from '@island.is/shared/translations'
+import { useLocale, useNamespaces } from '@island.is/localization'
+import { theme } from '@island.is/island-ui/theme'
+import { useWindowSize } from 'react-use'
+import { m } from '@island.is/service-portal/core'
+import NotificationLine from './NotificationLine'
+import cn from 'classnames'
+import * as styles from './Notifications.css'
+import * as mStyles from '../Sidemenu/Sidemenu.css'
+
+interface Props {
+ closeNotificationMenu: () => void
+ sideMenuOpen: boolean
+ rightPosition?: number
+ data?: GetUserNotificationsOverviewQuery
+}
+const NotificationMenu = ({
+ closeNotificationMenu,
+ sideMenuOpen,
+ rightPosition,
+ data,
+}: Props): ReactElement | null => {
+ useNamespaces(['service.portal'])
+ const { formatMessage } = useLocale()
+ const { width } = useWindowSize()
+
+ const isMobile = width < theme.breakpoints.md
+
+ const onClose = () => {
+ closeNotificationMenu()
+ }
+
+ const closeButton = (
+
+
+
+ )
+
+ const content = (
+
+
+
+
+
+
+
+ {formatMessage(m.notifications)}
+
+
+ {(data?.userNotificationsOverview?.data ?? []).map((item, i) => (
+
+ ))}
+
+
+
+ {formatMessage(m.notificationsViewAll)}
+
+
+
+
+
+ {closeButton}
+
+
+ )
+
+ return isMobile ? (
+
+ {content}
+
+ ) : (
+ {
+ if (visibility !== sideMenuOpen) {
+ onClose()
+ }
+ }}
+ >
+ {content}
+
+ )
+}
+
+export default NotificationMenu
diff --git a/apps/service-portal/src/components/Notifications/Notifications.css.ts b/apps/service-portal/src/components/Notifications/Notifications.css.ts
new file mode 100644
index 000000000000..afa15da1d821
--- /dev/null
+++ b/apps/service-portal/src/components/Notifications/Notifications.css.ts
@@ -0,0 +1,49 @@
+import { theme, themeUtils } from '@island.is/island-ui/theme'
+import { style, globalStyle } from '@vanilla-extract/css'
+
+export const navWrapper = style({
+ paddingTop: theme.spacing[1],
+})
+
+export const link = style({
+ overflow: 'hidden',
+})
+
+globalStyle(`${link} > span`, {
+ boxShadow: 'none',
+})
+
+export const badge = style({
+ position: 'absolute',
+ top: 11,
+ right: 16,
+ height: theme.spacing[1],
+ width: theme.spacing[1],
+ borderRadius: '50%',
+ backgroundColor: theme.color.red400,
+ ...themeUtils.responsiveStyle({
+ md: {
+ top: 14,
+ },
+ }),
+})
+
+// Line
+
+export const lineWrapper = style({
+ width: '100%',
+})
+
+export const line = style({
+ paddingTop: theme.spacing.smallGutter * 3,
+ paddingBottom: theme.spacing.smallGutter * 3,
+ selectors: {
+ '&:hover': {
+ backgroundColor: theme.color.blue100,
+ },
+ },
+})
+
+export const unread = style({
+ backgroundColor: theme.color.blueberry100,
+})
diff --git a/apps/services/auth/delegation-api/infra/delegation-api.ts b/apps/services/auth/delegation-api/infra/delegation-api.ts
index 27d4a4e8a104..52148c250ec1 100644
--- a/apps/services/auth/delegation-api/infra/delegation-api.ts
+++ b/apps/services/auth/delegation-api/infra/delegation-api.ts
@@ -1,85 +1,96 @@
-import { json, service, ServiceBuilder } from '../../../../../infra/src/dsl/dsl'
+import {
+ json,
+ service,
+ ServiceBuilder,
+ ref,
+} from '../../../../../infra/src/dsl/dsl'
import { Base, Client, RskProcuring } from '../../../../../infra/src/dsl/xroad'
-export const serviceSetup =
- (): ServiceBuilder<'services-auth-delegation-api'> => {
- return service('services-auth-delegation-api')
- .namespace('identity-server-delegation')
- .image('services-auth-delegation-api')
- .db({
- name: 'servicesauth',
- })
- .env({
- IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api',
- IDENTITY_SERVER_ISSUER_URL: {
- dev: 'https://identity-server.dev01.devland.is',
- staging: 'https://identity-server.staging01.devland.is',
- prod: 'https://innskra.island.is',
+export const serviceSetup = (services: {
+ userNotification: ServiceBuilder<'user-notification'>
+}): ServiceBuilder<'services-auth-delegation-api'> => {
+ return service('services-auth-delegation-api')
+ .namespace('identity-server-delegation')
+ .image('services-auth-delegation-api')
+ .db({
+ name: 'servicesauth',
+ })
+ .env({
+ IDENTITY_SERVER_CLIENT_ID: '@island.is/clients/auth-api',
+ IDENTITY_SERVER_ISSUER_URL: {
+ dev: 'https://identity-server.dev01.devland.is',
+ staging: 'https://identity-server.staging01.devland.is',
+ prod: 'https://innskra.island.is',
+ },
+ XROAD_NATIONAL_REGISTRY_ACTOR_TOKEN: 'true',
+ XROAD_RSK_PROCURING_ACTOR_TOKEN: 'true',
+ XROAD_NATIONAL_REGISTRY_SERVICE_PATH: {
+ dev: 'IS-DEV/GOV/10001/SKRA-Protected/Einstaklingar-v1',
+ staging: 'IS-TEST/GOV/6503760649/SKRA-Protected/Einstaklingar-v1',
+ prod: 'IS/GOV/6503760649/SKRA-Protected/Einstaklingar-v1',
+ },
+ XROAD_NATIONAL_REGISTRY_REDIS_NODES: {
+ dev: json([
+ 'clustercfg.general-redis-cluster-group.5fzau3.euw1.cache.amazonaws.com:6379',
+ ]),
+ staging: json([
+ 'clustercfg.general-redis-cluster-group.ab9ckb.euw1.cache.amazonaws.com:6379',
+ ]),
+ prod: json([
+ 'clustercfg.general-redis-cluster-group.dnugi2.euw1.cache.amazonaws.com:6379',
+ ]),
+ },
+ XROAD_RSK_PROCURING_REDIS_NODES: {
+ dev: json([
+ 'clustercfg.general-redis-cluster-group.5fzau3.euw1.cache.amazonaws.com:6379',
+ ]),
+ staging: json([
+ 'clustercfg.general-redis-cluster-group.ab9ckb.euw1.cache.amazonaws.com:6379',
+ ]),
+ prod: json([
+ 'clustercfg.general-redis-cluster-group.dnugi2.euw1.cache.amazonaws.com:6379',
+ ]),
+ },
+ USER_NOTIFICATION_API_URL: {
+ dev: ref((h) => `http://${h.svc(services.userNotification)}`),
+ staging: ref((h) => `http://${h.svc(services.userNotification)}`),
+ prod: 'https://user-notification.internal.island.is',
+ },
+ })
+ .secrets({
+ IDENTITY_SERVER_CLIENT_SECRET:
+ '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET',
+ NATIONAL_REGISTRY_IDS_CLIENT_SECRET:
+ '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET',
+ })
+ .xroad(Base, Client, RskProcuring)
+ .readiness('/health/check')
+ .liveness('/liveness')
+ .replicaCount({
+ default: 2,
+ min: 2,
+ max: 10,
+ })
+ .resources({
+ limits: {
+ cpu: '400m',
+ memory: '512Mi',
+ },
+ requests: {
+ cpu: '100m',
+ memory: '256Mi',
+ },
+ })
+ .ingress({
+ internal: {
+ host: {
+ dev: 'auth-delegation-api',
+ staging: 'auth-delegation-api',
+ prod: 'auth-delegation-api.internal.innskra.island.is',
},
- XROAD_NATIONAL_REGISTRY_ACTOR_TOKEN: 'true',
- XROAD_RSK_PROCURING_ACTOR_TOKEN: 'true',
- XROAD_NATIONAL_REGISTRY_SERVICE_PATH: {
- dev: 'IS-DEV/GOV/10001/SKRA-Protected/Einstaklingar-v1',
- staging: 'IS-TEST/GOV/6503760649/SKRA-Protected/Einstaklingar-v1',
- prod: 'IS/GOV/6503760649/SKRA-Protected/Einstaklingar-v1',
- },
- XROAD_NATIONAL_REGISTRY_REDIS_NODES: {
- dev: json([
- 'clustercfg.general-redis-cluster-group.5fzau3.euw1.cache.amazonaws.com:6379',
- ]),
- staging: json([
- 'clustercfg.general-redis-cluster-group.ab9ckb.euw1.cache.amazonaws.com:6379',
- ]),
- prod: json([
- 'clustercfg.general-redis-cluster-group.dnugi2.euw1.cache.amazonaws.com:6379',
- ]),
- },
- XROAD_RSK_PROCURING_REDIS_NODES: {
- dev: json([
- 'clustercfg.general-redis-cluster-group.5fzau3.euw1.cache.amazonaws.com:6379',
- ]),
- staging: json([
- 'clustercfg.general-redis-cluster-group.ab9ckb.euw1.cache.amazonaws.com:6379',
- ]),
- prod: json([
- 'clustercfg.general-redis-cluster-group.dnugi2.euw1.cache.amazonaws.com:6379',
- ]),
- },
- })
- .secrets({
- IDENTITY_SERVER_CLIENT_SECRET:
- '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET',
- NATIONAL_REGISTRY_IDS_CLIENT_SECRET:
- '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET',
- })
- .xroad(Base, Client, RskProcuring)
- .readiness('/health/check')
- .liveness('/liveness')
- .replicaCount({
- default: 2,
- min: 2,
- max: 10,
- })
- .resources({
- limits: {
- cpu: '400m',
- memory: '512Mi',
- },
- requests: {
- cpu: '100m',
- memory: '256Mi',
- },
- })
- .ingress({
- internal: {
- host: {
- dev: 'auth-delegation-api',
- staging: 'auth-delegation-api',
- prod: 'auth-delegation-api.internal.innskra.island.is',
- },
- paths: ['/'],
- public: false,
- },
- })
- .grantNamespaces('nginx-ingress-internal', 'islandis')
- }
+ paths: ['/'],
+ public: false,
+ },
+ })
+ .grantNamespaces('nginx-ingress-internal', 'islandis')
+}
diff --git a/apps/services/auth/delegation-api/src/app/app.module.ts b/apps/services/auth/delegation-api/src/app/app.module.ts
index ca5d53d4561b..351811ae61a2 100644
--- a/apps/services/auth/delegation-api/src/app/app.module.ts
+++ b/apps/services/auth/delegation-api/src/app/app.module.ts
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
import { SequelizeModule } from '@nestjs/sequelize'
import {
+ DelegationApiUserSystemNotificationConfig,
DelegationConfig,
SequelizeConfigService,
} from '@island.is/auth-api-lib'
@@ -46,6 +47,7 @@ import { ScopesModule } from './scopes/scopes.module'
NationalRegistryClientConfig,
RskRelationshipsClientConfig,
XRoadConfig,
+ DelegationApiUserSystemNotificationConfig,
],
}),
],
diff --git a/apps/services/auth/delegation-api/src/app/delegations/delegations.controller.ts b/apps/services/auth/delegation-api/src/app/delegations/delegations.controller.ts
index de6d8adf63a1..9b5ffbb6147b 100644
--- a/apps/services/auth/delegation-api/src/app/delegations/delegations.controller.ts
+++ b/apps/services/auth/delegation-api/src/app/delegations/delegations.controller.ts
@@ -2,6 +2,7 @@ import { Controller, Get, Headers, Query, UseGuards } from '@nestjs/common'
import { ApiSecurity, ApiTags } from '@nestjs/swagger'
import {
+ DelegationDirection,
DelegationsIndexService,
PaginatedDelegationRecordDTO,
} from '@island.is/auth-api-lib'
@@ -40,9 +41,9 @@ export class DelegationsController {
response: { status: 200, type: PaginatedDelegationRecordDTO },
request: {
header: {
- 'X-Query-From-National-Id': {
+ 'X-Query-National-Id': {
required: true,
- description: 'fetch delegations from this national id',
+ description: 'fetch delegations for this national id',
},
},
query: {
@@ -51,13 +52,23 @@ export class DelegationsController {
type: 'string',
description: 'fetch delegations that have access to this scope',
},
+ direction: {
+ description:
+ 'The direction of the delegation. Defaults to outgoing if not provided.',
+ required: false,
+ schema: {
+ enum: [DelegationDirection.OUTGOING, DelegationDirection.INCOMING],
+ default: DelegationDirection.OUTGOING,
+ },
+ },
},
},
})
async getDelegationRecords(
@CurrentAuth() auth: Auth,
- @Headers('X-Query-From-National-Id') fromNationalId: string,
+ @Headers('X-Query-National-Id') nationalId: string,
@Query('scope') scope: string,
+ @Query('direction') direction = DelegationDirection.OUTGOING,
): Promise {
return this.auditService.auditPromise(
{
@@ -66,12 +77,14 @@ export class DelegationsController {
resources: (delegations) => delegations.data.map((d) => d.toNationalId),
meta: {
scope,
- fromNationalId,
+ nationalId,
+ direction,
},
},
this.delegationIndexService.getDelegationRecords({
scope,
- fromNationalId,
+ nationalId,
+ direction,
}),
)
}
diff --git a/apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index-test-cases.ts b/apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index-test-cases.ts
index e5e8694bdee5..3518b529636f 100644
--- a/apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index-test-cases.ts
+++ b/apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index-test-cases.ts
@@ -68,4 +68,14 @@ export const indexingTestCases: Record = {
expectedFrom: [adult1],
},
),
+ singleCustomDelegation: new TestCase(
+ createClient({
+ clientId: clientId,
+ supportsCustomDelegation: true,
+ }),
+ {
+ fromCustom: [adult1],
+ expectedFrom: [adult1],
+ },
+ ),
}
diff --git a/apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index.spec.ts b/apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index.service.spec.ts
similarity index 76%
rename from apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index.spec.ts
rename to apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index.service.spec.ts
index 58dc54e28e8e..de79d945c7a5 100644
--- a/apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index.spec.ts
+++ b/apps/services/auth/delegation-api/src/app/delegations/test/delegation-index/delegation-index.service.spec.ts
@@ -11,11 +11,18 @@ import {
DelegationIndexMeta,
Delegation,
DelegationScope,
- DelegationType,
+ audkenniProvider,
+ delegationProvider,
+ actorSubjectIdType,
+ UserIdentitiesService,
} from '@island.is/auth-api-lib'
import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2'
import { FixtureFactory } from '@island.is/services/auth/testing'
import { RskRelationshipsClient } from '@island.is/clients-rsk-relationships'
+import {
+ AuthDelegationProvider,
+ AuthDelegationType,
+} from '@island.is/shared/types'
import { indexingTestCases, prRight1 } from './delegation-index-test-cases'
import { domainName, TestCase, user } from './delegations-index-types'
@@ -34,6 +41,9 @@ describe('DelegationsIndexService', () => {
let rskApi: RskRelationshipsClient
let delegationModel: typeof Delegation
let delegationScopeModel: typeof DelegationScope
+ let userIdentitiesService: UserIdentitiesService
+
+ const userIdentitySubjectId1 = faker.datatype.uuid()
const setup = async (testCase: TestCase) => {
await truncate(sequelize)
@@ -58,6 +68,15 @@ describe('DelegationsIndexService', () => {
),
)
+ // Create user identity for user with audkenni provider
+ await factory.createUserIdentity({
+ subjectId: userIdentitySubjectId1,
+ name: faker.name.findName(),
+ providerName: audkenniProvider,
+ providerSubjectId: `IS-${user.nationalId}`,
+ active: true,
+ })
+
// mock national registry for ward delegations
jest
.spyOn(nationalRegistryApi, 'getCustodyChildren')
@@ -75,6 +94,8 @@ describe('DelegationsIndexService', () => {
})
delegationIndexService = app.get(DelegationsIndexService)
+ userIdentitiesService = app.get(UserIdentitiesService)
+
delegationIndexModel = app.get(getModelToken(DelegationIndex))
delegationIndexMetaModel = app.get(getModelToken(DelegationIndexMeta))
delegationModel = app.get(getModelToken(Delegation))
@@ -244,7 +265,7 @@ describe('DelegationsIndexService', () => {
where: {
fromNationalId,
toNationalId: user.nationalId,
- type: DelegationType.Custom,
+ type: AuthDelegationType.Custom,
},
})
@@ -456,9 +477,120 @@ describe('DelegationsIndexService', () => {
expect(testCase.expectedFrom).toContain(delegation.fromNationalId)
expect(delegation.toNationalId).toBe(user.nationalId)
expect(delegation.type).toBe(
- `${DelegationType.PersonalRepresentative}:${prRight1}`,
+ `${AuthDelegationType.PersonalRepresentative}:${prRight1}`,
)
})
})
})
+
+ describe('SubjectId', () => {
+ const testCase = indexingTestCases.singleCustomDelegation
+
+ beforeEach(async () => setup(testCase))
+
+ afterEach(async () => {
+ // remove all data
+ await delegationIndexMetaModel.destroy({ where: {} })
+ await delegationIndexModel.destroy({ where: {} })
+ })
+
+ it('should reuse subjectId from delegations with same fromNationalId and toNationalId', async () => {
+ const fromNationalId = testCase.customDelegations[0].fromNationalId
+
+ const findOrCreateSubjectIdSpy = jest.spyOn(
+ userIdentitiesService,
+ 'findOrCreateSubjectId',
+ )
+
+ // Arrange
+ // create delegation and delegation index record with same to and from national id
+ await factory.createPersonalRepresentativeDelegation({
+ fromNationalId,
+ toNationalId: user.nationalId,
+ rightTypes: [{ code: 'right' }],
+ })
+ await factory.createDelegationIndexRecord({
+ fromNationalId,
+ toNationalId: user.nationalId,
+ provider: AuthDelegationProvider.PersonalRepresentativeRegistry,
+ type: `${AuthDelegationType.PersonalRepresentative}:right`,
+ subjectId: userIdentitySubjectId1,
+ })
+
+ // Act
+ await delegationIndexService.indexDelegations(user)
+
+ // Assert
+ const delegations = await delegationIndexModel.findAll({
+ where: {
+ toNationalId: user.nationalId,
+ fromNationalId,
+ },
+ })
+
+ // Should have two delegations with the same subjectId
+ expect(delegations.length).toEqual(2)
+
+ // should not call findOrCreateSubjectId because we are reusing subjectId from delegation in the index
+ expect(userIdentitiesService.findOrCreateSubjectId).not.toHaveBeenCalled()
+
+ findOrCreateSubjectIdSpy.mockClear()
+ })
+
+ it('should fetch subjectId if userIdentity exits', async () => {
+ const fromNationalId = testCase.customDelegations[0].fromNationalId
+ const userIdentitySubjectId2 = faker.datatype.uuid()
+
+ // User identity with delegation provider
+ await factory.createUserIdentity({
+ subjectId: userIdentitySubjectId2,
+ name: faker.name.findName(),
+ providerName: delegationProvider,
+ providerSubjectId: `IS-${fromNationalId}`,
+ active: true,
+ })
+
+ // delegation claim
+ await factory.createClaim({
+ subjectId: userIdentitySubjectId2,
+ type: actorSubjectIdType,
+ value: userIdentitySubjectId1,
+ valueType: faker.random.word(),
+ issuer: faker.random.word(),
+ originalIssuer: faker.random.word(),
+ })
+
+ // Act
+ await delegationIndexService.indexDelegations(user)
+
+ // Assert
+ const delegations = await delegationIndexModel.findAll({
+ where: {
+ toNationalId: user.nationalId,
+ fromNationalId,
+ subjectId: userIdentitySubjectId2,
+ },
+ })
+
+ expect(delegations.length).toEqual(1)
+ })
+
+ it('should create subjectId if userIdentity does not exits', async () => {
+ const fromNationalId = testCase.customDelegations[0].fromNationalId
+
+ // Act
+ await delegationIndexService.indexDelegations(user)
+
+ // Assert
+ const delegations = await delegationIndexModel.findAll({
+ where: {
+ toNationalId: user.nationalId,
+ fromNationalId,
+ },
+ })
+
+ expect(delegations.length).toEqual(1)
+ expect(delegations[0].subjectId).toBeDefined()
+ })
+ })
})
diff --git a/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller-test-types.ts b/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller-test-types.ts
index 00d775d0f8f0..0ae9ee628fc6 100644
--- a/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller-test-types.ts
+++ b/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller-test-types.ts
@@ -10,7 +10,10 @@ import {
AuthDelegationProvider,
AuthDelegationType,
} from '@island.is/shared/types'
-import { PersonalRepresentativeDelegationType } from '@island.is/auth-api-lib'
+import {
+ DelegationDirection,
+ PersonalRepresentativeDelegationType,
+} from '@island.is/auth-api-lib'
import { createClient } from '@island.is/services/auth/testing'
import { AuthScope } from '@island.is/auth/scopes'
@@ -44,18 +47,28 @@ type PersonalRepresentativeDelegationInput = DelegationRecordInput & {
type GetDelegationIndexRecordsInput = {
scope: string
- fromNationalId: string
+ nationalId: string
+ direction?: DelegationDirection
}
-export interface ITestCaseOptions {
+type TestCaseResults =
+ | {
+ expectedFrom: string[] // used to test incoming delegations
+ expectedTo?: never
+ }
+ | {
+ expectedTo: string[] // used to test outgoing delegations
+ expectedFrom?: never
+ }
+
+export type ITestCaseOptions = {
requestUser: GetDelegationIndexRecordsInput
toParents?: DelegationRecordInput[]
toProcurationHolders?: DelegationRecordInput[]
toCustom?: CustomDelegationRecordInput[]
toRepresentative?: PersonalRepresentativeDelegationInput[]
scopes?: Scope[]
- expectedTo: string[]
-}
+} & TestCaseResults
type Scope = {
name: string
@@ -114,7 +127,8 @@ export class TestCase {
toCustom: CustomDelegationRecordInput[]
toRepresentative: PersonalRepresentativeDelegationInput[]
scopes: Scope[]
- expectedTo: string[]
+ expectedTo?: string[]
+ expectedFrom?: string[]
constructor(options: ITestCaseOptions) {
this.client = createClient({ clientId })
@@ -125,6 +139,7 @@ export class TestCase {
this.toRepresentative = options.toRepresentative ?? []
this.scopes = options.scopes ?? Object.values(scopes)
this.expectedTo = options.expectedTo
+ this.expectedFrom = options.expectedFrom
}
get domain(): CreateDomain {
@@ -146,7 +161,7 @@ export class TestCase {
get customDelegationRecord(): CreateDelegationIndexRecord[] {
return this.toCustom.map((record) => ({
- fromNationalId: record.fromNationalId ?? this.requestUser.fromNationalId,
+ fromNationalId: record.fromNationalId ?? this.requestUser.nationalId,
toNationalId: record.toNationalId,
provider: AuthDelegationProvider.Custom,
type: AuthDelegationType.Custom,
@@ -157,7 +172,7 @@ export class TestCase {
get procurationDelegationRecords(): CreateDelegationIndexRecord[] {
return this.toProcurationHolders.map((record) => ({
- fromNationalId: record.fromNationalId ?? this.requestUser.fromNationalId,
+ fromNationalId: record.fromNationalId ?? this.requestUser.nationalId,
toNationalId: record.toNationalId,
provider: AuthDelegationProvider.CompanyRegistry,
type: AuthDelegationType.ProcurationHolder,
@@ -166,7 +181,7 @@ export class TestCase {
get personalRepresentativeDelegationRecords(): CreateDelegationIndexRecord[] {
return this.toRepresentative.map((record) => ({
- fromNationalId: record.fromNationalId ?? this.requestUser.fromNationalId,
+ fromNationalId: record.fromNationalId ?? this.requestUser.nationalId,
toNationalId: record.toNationalId,
provider: AuthDelegationProvider.PersonalRepresentativeRegistry,
type: record.type,
@@ -176,7 +191,7 @@ export class TestCase {
get wardDelegationRecords(): CreateDelegationIndexRecord[] {
return this.toParents.map((record) => ({
- fromNationalId: record.fromNationalId ?? this.requestUser.fromNationalId,
+ fromNationalId: record.fromNationalId ?? this.requestUser.nationalId,
toNationalId: record.toNationalId,
provider: AuthDelegationProvider.NationalRegistry,
type: AuthDelegationType.LegalGuardian,
diff --git a/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller.spec.ts b/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller.spec.ts
index e09795fd8374..853e4f0c9ca3 100644
--- a/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller.spec.ts
+++ b/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller.spec.ts
@@ -92,15 +92,34 @@ describe('DelegationsController', () => {
// Act
const response = await server
.get(path)
- .set('X-Query-From-National-Id', testCase.requestUser.fromNationalId)
- .query({ scope: testCase.requestUser.scope })
+ .set('X-Query-National-Id', testCase.requestUser.nationalId)
+ .query({
+ scope: testCase.requestUser.scope,
+ direction: testCase.requestUser.direction,
+ })
// Assert
expect(response.status).toBe(200)
- expect(response.body.totalCount).toEqual(testCase.expectedTo.length)
- response.body.data.forEach((record: DelegationRecordDTO) => {
- expect(testCase.expectedTo.includes(record.toNationalId)).toBe(true)
- })
+
+ if (testCase.expectedTo) {
+ expect(response.body.totalCount).toEqual(testCase.expectedTo?.length)
+ response.body.data.forEach((record: DelegationRecordDTO) => {
+ expect(testCase.expectedTo?.includes(record.toNationalId)).toBe(
+ true,
+ )
+ })
+ }
+
+ if (testCase.expectedFrom) {
+ expect(response.body.totalCount).toEqual(
+ testCase.expectedFrom?.length,
+ )
+ response.body.data.forEach((record: DelegationRecordDTO) => {
+ expect(testCase.expectedFrom?.includes(record.fromNationalId)).toBe(
+ true,
+ )
+ })
+ }
})
},
)
@@ -126,8 +145,11 @@ describe('DelegationsController', () => {
// Act
const response = await server
.get(path)
- .set('X-Query-From-National-Id', testCase.requestUser.fromNationalId)
- .query({ scope: testCase.requestUser.scope })
+ .set('X-Query-National-Id', testCase.requestUser.nationalId)
+ .query({
+ scope: testCase.requestUser.scope,
+ direction: testCase.requestUser.direction,
+ })
// Assert
expect(response.status).toBe(400)
@@ -154,8 +176,11 @@ describe('DelegationsController', () => {
// Act
const response = await server
.get(path)
- .set('X-Query-From-National-Id', user.nationalId)
- .query({ scope: testCase.requestUser.scope })
+ .set('X-Query-National-Id', user.nationalId)
+ .query({
+ scope: testCase.requestUser.scope,
+ direction: testCase.requestUser.direction,
+ })
// Assert
expect(response.status).toBe(403)
diff --git a/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller.test-cases.ts b/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller.test-cases.ts
index 866ebee5e872..c08e03d314a9 100644
--- a/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller.test-cases.ts
+++ b/apps/services/auth/delegation-api/src/app/delegations/test/delegations-controller/delegations.controller.test-cases.ts
@@ -4,7 +4,10 @@ import {
scopes,
TestCase,
} from './delegations.controller-test-types'
-import { PersonalRepresentativeDelegationType } from '@island.is/auth-api-lib'
+import {
+ DelegationDirection,
+ PersonalRepresentativeDelegationType,
+} from '@island.is/auth-api-lib'
const person1 = createNationalId('person')
const person2 = createNationalId('person')
@@ -23,7 +26,7 @@ export const validTestCases: Record<
message: 'Should only return legal guardian delegations',
testCase: new TestCase({
requestUser: {
- fromNationalId: person1,
+ nationalId: person1,
scope: scopes.legalGuardian.name,
},
toParents: [
@@ -44,7 +47,7 @@ export const validTestCases: Record<
message: 'Should only return procuration delegations',
testCase: new TestCase({
requestUser: {
- fromNationalId: company1,
+ nationalId: company1,
scope: scopes.procurationHolder.name,
},
toParents: [
@@ -68,7 +71,7 @@ export const validTestCases: Record<
message: 'Should only return custom delegations',
testCase: new TestCase({
requestUser: {
- fromNationalId: company1,
+ nationalId: company1,
scope: scopes.custom.name,
},
toCustom: [
@@ -94,7 +97,7 @@ export const validTestCases: Record<
'Should only return custom delegations that have the same scope as the request and have valid validTo date',
testCase: new TestCase({
requestUser: {
- fromNationalId: company1,
+ nationalId: company1,
scope: scopes.custom.name,
},
toCustom: [
@@ -120,7 +123,7 @@ export const validTestCases: Record<
message: 'Should not return any delegations for scope that grants nothing',
testCase: new TestCase({
requestUser: {
- fromNationalId: company1,
+ nationalId: company1,
scope: scopes.none.name,
},
toCustom: [
@@ -143,7 +146,7 @@ export const validTestCases: Record<
message: 'Should only return personal representative delegations',
testCase: new TestCase({
requestUser: {
- fromNationalId: company1,
+ nationalId: company1,
scope: scopes.representative.name,
},
toCustom: [
@@ -167,7 +170,7 @@ export const validTestCases: Record<
'Should only return personal representative delegations if the scope has permission for the right type and it has valid validTo date',
testCase: new TestCase({
requestUser: {
- fromNationalId: company1,
+ nationalId: company1,
scope: scopes.representative.name,
},
toRepresentative: [
@@ -194,7 +197,7 @@ export const validTestCases: Record<
'Should only return delegations that are from the requested national id ',
testCase: new TestCase({
requestUser: {
- fromNationalId: company1,
+ nationalId: company1,
scope: scopes.all.name,
},
toRepresentative: [
@@ -227,6 +230,37 @@ export const validTestCases: Record<
expectedTo: [],
}),
},
+ incomingDelegations: {
+ message: 'should only return incoming delegations for the national id',
+ testCase: new TestCase({
+ requestUser: {
+ nationalId: person1,
+ scope: scopes.all.name,
+ direction: DelegationDirection.INCOMING,
+ },
+ toCustom: [
+ {
+ fromNationalId: person3,
+ toNationalId: person1,
+ scopes: [scopes.all.name],
+ },
+ ],
+ toProcurationHolders: [
+ {
+ fromNationalId: person2,
+ toNationalId: person1,
+ },
+ ],
+ toParents: [
+ {
+ fromNationalId: child1,
+ toNationalId: person1,
+ },
+ ],
+ scopes: [scopes.all], // has grants for all delegations
+ expectedFrom: [person3, person2, child1],
+ }),
+ },
}
export const invalidTestCases: Record<
@@ -238,7 +272,7 @@ export const invalidTestCases: Record<
errorMessage: 'Invalid scope',
testCase: new TestCase({
requestUser: {
- fromNationalId: company1,
+ nationalId: company1,
scope: 'non-existing-scope',
},
scopes: [scopes.none],
@@ -250,7 +284,7 @@ export const invalidTestCases: Record<
errorMessage: 'Invalid national id',
testCase: new TestCase({
requestUser: {
- fromNationalId: 'invalid-national-id',
+ nationalId: 'invalid-national-id',
scope: scopes.representative.name,
},
scopes: [scopes.representative],
diff --git a/apps/services/auth/delegation-api/src/app/delegations/test/me-delegations.access-outgoing.spec.ts b/apps/services/auth/delegation-api/src/app/delegations/test/me-delegations.access-outgoing.spec.ts
index e065ffb2516b..3a13ab7ba6e5 100644
--- a/apps/services/auth/delegation-api/src/app/delegations/test/me-delegations.access-outgoing.spec.ts
+++ b/apps/services/auth/delegation-api/src/app/delegations/test/me-delegations.access-outgoing.spec.ts
@@ -14,6 +14,7 @@ import {
Domain,
NamesService,
PatchDelegationDTO,
+ UserIdentitiesService,
} from '@island.is/auth-api-lib'
import { isDefined } from '@island.is/shared/utils'
import { createNationalId } from '@island.is/testing/fixtures'
@@ -45,9 +46,17 @@ describe.each(Object.keys(accessOutgoingTestCases))(
app = await setupWithAuth({
user: testCase.user,
customScopeRules: testCase.customScopeRules,
+ // user identities service is used in delegation index service, override it to mock the response
+ override: (builder) =>
+ builder.overrideProvider(UserIdentitiesService).useValue({
+ findOrCreateSubjectId: jest
+ .fn()
+ .mockResolvedValue(faker.datatype.uuid()),
+ }),
})
server = request(app.getHttpServer())
const namesService = app.get(NamesService)
+
jest
.spyOn(namesService, 'getPersonName')
.mockResolvedValue(faker.name.findName())
diff --git a/apps/services/auth/ids-api/src/app/user-profile/user-profile.controller.spec.ts b/apps/services/auth/ids-api/src/app/user-profile/user-profile.controller.spec.ts
index 7a660c565183..44ab837092ad 100644
--- a/apps/services/auth/ids-api/src/app/user-profile/user-profile.controller.spec.ts
+++ b/apps/services/auth/ids-api/src/app/user-profile/user-profile.controller.spec.ts
@@ -1,5 +1,5 @@
-import request from 'supertest'
import * as faker from 'faker'
+import request from 'supertest'
import {
IndividualDto,
@@ -11,19 +11,19 @@ import {
CompanyRegistryClientService,
} from '@island.is/clients/rsk/company-registry'
import {
- UserProfile,
- UserProfileApi,
+ UserProfileDto,
UserProfileLocaleEnum,
+ V2MeApi,
} from '@island.is/clients/user-profile'
-import { TestApp } from '@island.is/testing/nest'
+import { Logger, LOGGER_PROVIDER } from '@island.is/logging'
import {
createCurrentUser,
createNationalId,
createNationalRegistryUser,
} from '@island.is/testing/fixtures'
+import { TestApp } from '@island.is/testing/nest'
import { setupWithAuth } from '../../../test/setup'
-import { Logger, LOGGER_PROVIDER } from '@island.is/logging'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mocked any>(value: T) {
@@ -69,23 +69,21 @@ function createCompany(): CompanyExtendedInfo {
} as CompanyExtendedInfo
}
-function createUserProfile(): UserProfile {
+function createUserProfile({ isRestricted = false }): UserProfileDto {
return {
+ nationalId: faker.datatype.string(),
email: faker.internet.email(),
- emailVerified: faker.datatype.boolean(),
mobilePhoneNumber: faker.phone.phoneNumber(),
- mobilePhoneNumberVerified: faker.datatype.boolean(),
- profileImageUrl: faker.internet.url(),
locale: faker.random.arrayElement(
Object.values(UserProfileLocaleEnum) as UserProfileLocaleEnum[],
),
- created: faker.date.past(),
- id: faker.datatype.uuid(),
+ mobilePhoneNumberVerified: faker.datatype.boolean(),
+ emailVerified: faker.datatype.boolean(),
documentNotifications: faker.datatype.boolean(),
- emailStatus: faker.datatype.string(),
- mobileStatus: faker.datatype.string(),
- modified: faker.date.past(),
- nationalId: faker.datatype.string(),
+ profileImageUrl: faker.internet.url(),
+ needsNudge: false,
+ emailNotifications: false,
+ isRestricted,
}
}
@@ -121,7 +119,7 @@ describe('UserProfileController', () => {
.spyOn(app.get(LOGGER_PROVIDER), 'error')
.mockImplementation()
mocked(
- app.get(UserProfileApi).userProfileControllerFindOneByNationalId,
+ app.get(V2MeApi).meUserProfileControllerFindUserProfile,
).mockRejectedValue({ status: 404 })
mocked(
app.get(NationalRegistryClientService).getIndividual,
@@ -141,7 +139,7 @@ describe('UserProfileController', () => {
.spyOn(app.get(LOGGER_PROVIDER), 'error')
.mockImplementation()
mocked(
- app.get(UserProfileApi).userProfileControllerFindOneByNationalId,
+ app.get(V2MeApi).meUserProfileControllerFindUserProfile,
).mockRejectedValue(new Error('500'))
mocked(
app.get(NationalRegistryClientService).getIndividual,
@@ -157,12 +155,12 @@ describe('UserProfileController', () => {
it('with full registries should return all claims', async () => {
// Arrange
- const userProfile = createUserProfile()
+ const userProfile = createUserProfile({})
const individual = createNationalRegistryUser({
genderCode: faker.random.arrayElement(['1', '3']),
})
mocked(
- app.get(UserProfileApi).userProfileControllerFindOneByNationalId,
+ app.get(V2MeApi).meUserProfileControllerFindUserProfile,
).mockResolvedValue(userProfile)
mockNationalRegistry(app.get(NationalRegistryClientService), individual)
@@ -206,6 +204,53 @@ describe('UserProfileController', () => {
expect(res.body).toEqual(expected)
})
+ it('with isRestricted should not return email and phone data', async () => {
+ // Arrange
+ const userProfile = createUserProfile({ isRestricted: true })
+ const individual = createNationalRegistryUser({
+ genderCode: faker.random.arrayElement(['1', '3']),
+ })
+ mocked(
+ app.get(V2MeApi).meUserProfileControllerFindUserProfile,
+ ).mockResolvedValue(userProfile)
+ mockNationalRegistry(app.get(NationalRegistryClientService), individual)
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const domicile = individual.legalDomicile!
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const address = individual.residence!
+ const expected = {
+ address: {
+ formatted: `${address.streetAddress}\n${address.postalCode} ${address.locality}\nÍsland`,
+ locality: address.locality,
+ postalCode: address.postalCode,
+ streetAddress: address.streetAddress,
+ country: 'Ísland',
+ },
+ birthdate: individual.birthdate.toISOString().split('T')[0],
+ legalDomicile: {
+ formatted: `${domicile.streetAddress}\n${domicile.postalCode} ${domicile.locality}\nÍsland`,
+ streetAddress: domicile.streetAddress,
+ postalCode: domicile.postalCode,
+ locality: domicile.locality,
+ country: 'Ísland',
+ },
+ familyName: individual.familyName,
+ gender: 'male',
+ givenName: individual.givenName,
+ locale: userProfile.locale,
+ middleName: individual.middleName,
+ name: individual.name,
+ picture: userProfile.profileImageUrl,
+ }
+
+ // Act
+ const res = await server.get(path).expect(200)
+
+ // Assert
+ expect(res.body).toEqual(expected)
+ })
+
it('should return domicile as address if residence is missing', async () => {
// Arrange
const individual = createNationalRegistryUser()
diff --git a/apps/services/auth/ids-api/src/app/user-profile/user-profile.service.ts b/apps/services/auth/ids-api/src/app/user-profile/user-profile.service.ts
index b36caba0f1ae..480664168d7a 100644
--- a/apps/services/auth/ids-api/src/app/user-profile/user-profile.service.ts
+++ b/apps/services/auth/ids-api/src/app/user-profile/user-profile.service.ts
@@ -1,6 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'
-import type { Auth, User } from '@island.is/auth-nest-tools'
import { AuthMiddleware } from '@island.is/auth-nest-tools'
import { FetchError } from '@island.is/clients/middlewares'
import {
@@ -8,14 +7,15 @@ import {
NationalRegistryClientService,
} from '@island.is/clients/national-registry-v2'
import { CompanyRegistryClientService } from '@island.is/clients/rsk/company-registry'
-import { UserProfileApi } from '@island.is/clients/user-profile'
-import type { Logger } from '@island.is/logging'
+import { V2MeApi } from '@island.is/clients/user-profile'
import { LOGGER_PROVIDER } from '@island.is/logging'
-import type { GenderValue } from './dto/user-profile.dto'
-import { UserProfileDTO } from './dto/user-profile.dto'
import { AddressDTO } from './dto/address.dto'
+import { UserProfileDTO } from './dto/user-profile.dto'
+import type { Auth, User } from '@island.is/auth-nest-tools'
+import type { Logger } from '@island.is/logging'
+import type { GenderValue } from './dto/user-profile.dto'
interface Address extends NationalRegistryAddress {
country?: string
}
@@ -34,7 +34,7 @@ export class UserProfileService {
constructor(
private individualClient: NationalRegistryClientService,
- private userProfileApi: UserProfileApi,
+ private userProfileApi: V2MeApi,
private companyRegistryApi: CompanyRegistryClientService,
@Inject(LOGGER_PROVIDER)
private logger: Logger,
@@ -130,14 +130,18 @@ export class UserProfileService {
private async getClaimsFromUserProfile(auth: User): Promise {
const userProfile = await this.userProfileApiWithAuth(
auth,
- ).userProfileControllerFindOneByNationalId({
- nationalId: auth.nationalId,
- })
+ ).meUserProfileControllerFindUserProfile()
return {
- email: userProfile.email,
- emailVerified: userProfile.emailVerified,
- phoneNumber: userProfile.mobilePhoneNumber,
- phoneNumberVerified: userProfile.mobilePhoneNumberVerified,
+ email: userProfile.isRestricted ? undefined : userProfile.email,
+ emailVerified: userProfile.isRestricted
+ ? undefined
+ : userProfile.emailVerified,
+ phoneNumber: userProfile.isRestricted
+ ? undefined
+ : userProfile.mobilePhoneNumber,
+ phoneNumberVerified: userProfile.isRestricted
+ ? undefined
+ : userProfile.mobilePhoneNumberVerified,
locale: userProfile.locale,
picture: userProfile.profileImageUrl,
}
diff --git a/apps/services/auth/ids-api/test/setup.ts b/apps/services/auth/ids-api/test/setup.ts
index 17b899c10334..02c16084cb58 100644
--- a/apps/services/auth/ids-api/test/setup.ts
+++ b/apps/services/auth/ids-api/test/setup.ts
@@ -7,14 +7,14 @@ import {
SequelizeConfigService,
} from '@island.is/auth-api-lib'
import { IdsUserGuard, MockAuthGuard, User } from '@island.is/auth-nest-tools'
+import { RskRelationshipsClient } from '@island.is/clients-rsk-relationships'
import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2'
import { CompanyRegistryClientService } from '@island.is/clients/rsk/company-registry'
-import { RskRelationshipsClient } from '@island.is/clients-rsk-relationships'
-import { UserProfileApi } from '@island.is/clients/user-profile'
+import { V2MeApi } from '@island.is/clients/user-profile'
import { FeatureFlagService } from '@island.is/nest/feature-flags'
import {
- createDomain,
createApiScope,
+ createDomain,
CreateDomain,
} from '@island.is/services/auth/testing'
import {
@@ -74,7 +74,7 @@ class MockNationalRegistryClientService
class MockUserProfile {
withMiddleware = () => this
- userProfileControllerFindOneByNationalId = jest.fn().mockResolvedValue({})
+ meUserProfileControllerFindUserProfile = jest.fn().mockResolvedValue({})
}
interface SetupOptions {
@@ -103,7 +103,7 @@ export const setupWithAuth = async ({
)
.overrideProvider(NationalRegistryClientService)
.useClass(MockNationalRegistryClientService)
- .overrideProvider(UserProfileApi)
+ .overrideProvider(V2MeApi)
.useClass(MockUserProfile)
.overrideProvider(CompanyRegistryClientService)
.useValue({
diff --git a/apps/services/endorsements/api/README.md b/apps/services/endorsements/api/README.md
index ce2cce19e777..0471c016097b 100644
--- a/apps/services/endorsements/api/README.md
+++ b/apps/services/endorsements/api/README.md
@@ -51,10 +51,10 @@ yarn dev services-endorsements-api
And go to localhost once project is ready and started
```bash
-http://localhost:4246/
+http://localhost:4246/swagger
```
-After making changes to the module code, re-initalize app to autogenerate code for swagger, openapi, fetch client etc.
+After making changes to the module code, re-initialize app to autogenerate code for swagger, openapi, fetch client etc.
```bash
yarn dev-init services-endorsements-api
diff --git a/apps/services/endorsements/api/src/app/app.module.ts b/apps/services/endorsements/api/src/app/app.module.ts
index e1a8c4fe002d..2dad9deda6e8 100644
--- a/apps/services/endorsements/api/src/app/app.module.ts
+++ b/apps/services/endorsements/api/src/app/app.module.ts
@@ -14,7 +14,7 @@ import { EndorsementListModule } from './modules/endorsementList/endorsementList
import { SequelizeConfigService } from './sequelizeConfig.service'
import { AccessGuard } from './guards/accessGuard/access.guard'
import { LoggingModule } from '@island.is/logging'
-import { NationalRegistryClientConfig } from '@island.is/clients/national-registry-v2'
+import { NationalRegistryV3ClientConfig } from '@island.is/clients/national-registry-v3'
import {
ConfigModule,
IdsClientConfig,
@@ -33,7 +33,7 @@ import {
LoggingModule,
ConfigModule.forRoot({
isGlobal: true,
- load: [NationalRegistryClientConfig, IdsClientConfig, XRoadConfig],
+ load: [NationalRegistryV3ClientConfig, IdsClientConfig, XRoadConfig],
}),
],
providers: [
diff --git a/apps/services/endorsements/api/src/app/modules/endorsement/endorsement.module.ts b/apps/services/endorsements/api/src/app/modules/endorsement/endorsement.module.ts
index a45e7b397660..3f40acf7864b 100644
--- a/apps/services/endorsements/api/src/app/modules/endorsement/endorsement.module.ts
+++ b/apps/services/endorsements/api/src/app/modules/endorsement/endorsement.module.ts
@@ -7,11 +7,11 @@ import { EndorsementList } from '../endorsementList/endorsementList.model'
import { EndorsementListService } from '../endorsementList/endorsementList.service'
import { environment } from '../../../environments'
import { EmailModule } from '@island.is/email-service'
-import { NationalRegistryClientModule } from '@island.is/clients/national-registry-v2'
+import { NationalRegistryV3ClientModule } from '@island.is/clients/national-registry-v3'
@Module({
imports: [
- NationalRegistryClientModule,
+ NationalRegistryV3ClientModule,
SequelizeModule.forFeature([Endorsement, EndorsementList]),
EmailModule.register(environment.emailOptions),
],
diff --git a/apps/services/endorsements/api/src/app/modules/endorsement/endorsement.service.ts b/apps/services/endorsements/api/src/app/modules/endorsement/endorsement.service.ts
index a8217b2b3a0f..9c59d6eb29bf 100644
--- a/apps/services/endorsements/api/src/app/modules/endorsement/endorsement.service.ts
+++ b/apps/services/endorsements/api/src/app/modules/endorsement/endorsement.service.ts
@@ -13,7 +13,7 @@ import { Op, UniqueConstraintError } from 'sequelize'
import { EndorsementTag } from '../endorsementList/constants'
import { paginate } from '@island.is/nest/pagination'
import { ENDORSEMENT_SYSTEM_GENERAL_PETITION_TAGS } from '../../../environments/environment'
-import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2'
+import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3'
interface FindEndorsementInput {
listId: string
@@ -53,7 +53,7 @@ export class EndorsementService {
private endorsementModel: typeof Endorsement,
@Inject(LOGGER_PROVIDER)
private logger: Logger,
- private readonly nationalRegistryApiV2: NationalRegistryClientService,
+ private readonly nationalRegistryApiV3: NationalRegistryV3ClientService,
) {}
async findEndorsements({ listId }: FindEndorsementsInput, query: any) {
@@ -121,13 +121,15 @@ export class EndorsementService {
if (new Date() >= endorsementList.closedDate) {
throw new MethodNotAllowedException(['Unable to endorse closed list'])
}
- const person = await this.nationalRegistryApiV2.getIndividual(nationalId)
+ const person = await this.nationalRegistryApiV3.getAllDataIndividual(
+ nationalId,
+ )
const endorsement = {
endorser: nationalId,
endorsementListId: endorsementList.id,
meta: {
- fullName: person?.fullName,
- locality: person?.legalDomicile?.locality,
+ fullName: person?.fulltNafn?.fulltNafn,
+ locality: person?.rikisfang?.rikisfangKodi,
showName,
},
}
diff --git a/apps/services/endorsements/api/src/app/modules/endorsementList/e2e/findEndorsementsEndorsementList/findEndorsementsEndorsementList.spec.ts b/apps/services/endorsements/api/src/app/modules/endorsementList/e2e/findEndorsementsEndorsementList/findEndorsementsEndorsementList.spec.ts
index ad0b1b7bd73a..206a94c1f983 100644
--- a/apps/services/endorsements/api/src/app/modules/endorsementList/e2e/findEndorsementsEndorsementList/findEndorsementsEndorsementList.spec.ts
+++ b/apps/services/endorsements/api/src/app/modules/endorsementList/e2e/findEndorsementsEndorsementList/findEndorsementsEndorsementList.spec.ts
@@ -33,6 +33,5 @@ describe('findEndorsementsEndorsementList', () => {
.expect(200)
expect(Array.isArray(response.body.data)).toBeTruthy()
- expect(response.body.data).toHaveLength(2)
})
})
diff --git a/apps/services/endorsements/api/src/app/modules/endorsementList/endorsementList.module.ts b/apps/services/endorsements/api/src/app/modules/endorsementList/endorsementList.module.ts
index 4d024ac9c4dd..860a0bd10c2b 100644
--- a/apps/services/endorsements/api/src/app/modules/endorsementList/endorsementList.module.ts
+++ b/apps/services/endorsements/api/src/app/modules/endorsementList/endorsementList.module.ts
@@ -7,11 +7,11 @@ import { Endorsement } from '../endorsement/models/endorsement.model'
import { environment } from '../../../environments'
import { EmailModule } from '@island.is/email-service'
-import { NationalRegistryClientModule } from '@island.is/clients/national-registry-v2'
+import { NationalRegistryV3ClientModule } from '@island.is/clients/national-registry-v3'
@Module({
imports: [
- NationalRegistryClientModule,
+ NationalRegistryV3ClientModule,
SequelizeModule.forFeature([EndorsementList, Endorsement]),
EmailModule.register(environment.emailOptions),
],
diff --git a/apps/services/endorsements/api/src/app/modules/endorsementList/endorsementList.service.ts b/apps/services/endorsements/api/src/app/modules/endorsementList/endorsementList.service.ts
index e7c8530f1017..8cef2a0cc416 100644
--- a/apps/services/endorsements/api/src/app/modules/endorsementList/endorsementList.service.ts
+++ b/apps/services/endorsements/api/src/app/modules/endorsementList/endorsementList.service.ts
@@ -22,10 +22,7 @@ import { AdminPortalScope } from '@island.is/auth/scopes'
import { EmailService } from '@island.is/email-service'
import PDFDocument from 'pdfkit'
import getStream from 'get-stream'
-import {
- IndividualDto,
- NationalRegistryClientService,
-} from '@island.is/clients/national-registry-v2'
+import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3'
interface CreateInput extends EndorsementListDto {
owner: string
@@ -42,7 +39,7 @@ export class EndorsementListService {
private logger: Logger,
@Inject(EmailService)
private emailService: EmailService,
- private readonly nationalRegistryApiV2: NationalRegistryClientService,
+ private readonly nationalRegistryApiV3: NationalRegistryV3ClientService,
) {}
hasAdminScope(user: User): boolean {
@@ -139,7 +136,10 @@ export class EndorsementListService {
model: EndorsementList,
required: true,
as: 'endorsementList',
- where: { adminLock: false },
+ where: {
+ adminLock: false,
+ tags: { [Op.contains]: [ENDORSEMENT_SYSTEM_GENERAL_PETITION_TAGS] },
+ },
attributes: [
'id',
'title',
@@ -296,8 +296,8 @@ export class EndorsementListService {
}
try {
- const person = await this.nationalRegistryApiV2.getIndividual(owner)
- return person?.fullName ? person.fullName : ''
+ const person = await this.nationalRegistryApiV3.getName(owner)
+ return person?.fulltNafn ? person.fulltNafn : ''
} catch (e) {
if (e instanceof Error) {
this.logger.warn(
diff --git a/apps/services/license-api/src/app/modules/license/dto/verifyLicense.dto.ts b/apps/services/license-api/src/app/modules/license/dto/verifyLicense.dto.ts
index 6ab0907e9561..f053e3d58550 100644
--- a/apps/services/license-api/src/app/modules/license/dto/verifyLicense.dto.ts
+++ b/apps/services/license-api/src/app/modules/license/dto/verifyLicense.dto.ts
@@ -1,4 +1,4 @@
-import { IsNationalId } from '@island.is/nest/core'
+import { IsPersonNationalId } from '@island.is/nest/core'
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'
@@ -18,7 +18,7 @@ class PassIdentity {
@IsString()
readonly name!: string
@ApiProperty({ description: "The scanned user's national id" })
- @IsNationalId()
+ @IsPersonNationalId()
readonly nationalId!: string
@ApiPropertyOptional({ description: 'Picture of scanned user' })
@IsOptional()
diff --git a/apps/services/search-indexer/README.md b/apps/services/search-indexer/README.md
index 6cf8ed4f5b95..4a07ce861ef6 100644
--- a/apps/services/search-indexer/README.md
+++ b/apps/services/search-indexer/README.md
@@ -19,6 +19,16 @@ The indexer server currently has two endpoints:
- `/re-sync` indexes all supported entries into elasticsearch
- `/sync` indexes all supported entries **since last sync** into elasticsearch
+## Nested entries
+
+If a nested entry (like an accordion) gets updated, then for that change to be visible on the web, its page (an article) would need to be re-indexed.
+
+This process can take a while, especially if a long time has passed since the last sync. That is why by default locally it's turned off, but if you'd like to turn it back on, you can set the `FORCE_SEARCH_INDEXER_TO_RESOLVE_NESTED_ENTRIES` environment variable to a truthy string value.
+
+## Web Cache Invalidation
+
+After an entry gets indexed it's not necessarily certain that it's web page will be updated immediately (due to caching). However there is a way to invalidate the cache via a query parameter. The search indexer has a process where after indexing it visits the corresponding web page with the given query parameter to refresh the cache. This cache invalidation process is turned off by default locally but you can turn it on by setting the `FORCE_CACHE_INVALIDATION_AFTER_INDEXING` environment variable to a truthy string value.
+
## Code owners and maintainers
- [Stefna](https://github.com/orgs/island-is/teams/stefna/members)
diff --git a/apps/services/university-gateway/infra/university-gateway.ts b/apps/services/university-gateway/infra/university-gateway.ts
index e0ae072dba7a..f56f1cf8f8ae 100644
--- a/apps/services/university-gateway/infra/university-gateway.ts
+++ b/apps/services/university-gateway/infra/university-gateway.ts
@@ -71,7 +71,7 @@ export const serviceSetup = (): ServiceBuilder => {
})
.liveness('/liveness')
.readiness('/liveness')
- .grantNamespaces('islandis', 'nginx-ingress-internal')
+ .grantNamespaces('islandis', 'nginx-ingress-internal', 'application-system')
}
export const workerSetup = (): ServiceBuilder => {
diff --git a/apps/services/university-gateway/src/app/modules/program/model/program.ts b/apps/services/university-gateway/src/app/modules/program/model/program.ts
index a37399189451..8c7d765c694e 100644
--- a/apps/services/university-gateway/src/app/modules/program/model/program.ts
+++ b/apps/services/university-gateway/src/app/modules/program/model/program.ts
@@ -317,6 +317,28 @@ export class ProgramBase extends Model<
@ApiHideProperty()
@UpdatedAt
readonly modified!: CreationOptional
+
+ @ApiProperty({
+ description:
+ 'Whether the application period for the program is open and applications can be submitted',
+ example: true,
+ })
+ @Column({
+ type: DataType.BOOLEAN,
+ allowNull: false,
+ })
+ applicationPeriodOpen!: boolean
+
+ @ApiProperty({
+ description:
+ 'Whether applications for the program should be submitted via University Gateway or the application portals of each university',
+ example: true,
+ })
+ @Column({
+ type: DataType.BOOLEAN,
+ allowNull: false,
+ })
+ applicationInUniversityGateway!: boolean
}
/*
This Model is for program information that are passed into the application, it doesn't need all the values passed to the Program model or ProgramBase so a new model was created with the necessary information
@@ -459,26 +481,4 @@ export class Program extends ProgramBase {
})
@HasMany(() => ProgramExtraApplicationField)
extraApplicationFields?: ProgramExtraApplicationField[]
-
- @ApiProperty({
- description:
- 'Whether the application period for the program is open and applications can be submitted',
- example: true,
- })
- @Column({
- type: DataType.BOOLEAN,
- allowNull: false,
- })
- applicationPeriodOpen!: boolean
-
- @ApiProperty({
- description:
- 'Whether applications for the program should be submitted via University Gateway or the application portals of each university',
- example: true,
- })
- @Column({
- type: DataType.BOOLEAN,
- allowNull: false,
- })
- applicationInUniversityGateway!: boolean
}
diff --git a/apps/services/user-notification/infra/user-notification.ts b/apps/services/user-notification/infra/user-notification.ts
index afe64d8640f4..0273f8e857c5 100644
--- a/apps/services/user-notification/infra/user-notification.ts
+++ b/apps/services/user-notification/infra/user-notification.ts
@@ -17,6 +17,36 @@ const imageName = `services-${serviceName}`
const MAIN_QUEUE_NAME = serviceName
const DEAD_LETTER_QUEUE_NAME = `${serviceName}-failure`
+const getEnv = (services: {
+ userProfileApi: ServiceBuilder<'service-portal-api'>
+}) => ({
+ MAIN_QUEUE_NAME,
+ DEAD_LETTER_QUEUE_NAME,
+ IDENTITY_SERVER_ISSUER_URL: {
+ dev: 'https://identity-server.dev01.devland.is',
+ staging: 'https://identity-server.staging01.devland.is',
+ prod: 'https://innskra.island.is',
+ },
+ USER_PROFILE_CLIENT_URL: ref(
+ (ctx) => `http://${ctx.svc(services.userProfileApi)}`,
+ ),
+ AUTH_DELEGATION_API_URL: {
+ dev: 'http://web-services-auth-delegation-api.identity-server-delegation.svc.cluster.local',
+ staging:
+ 'http://web-services-auth-delegation-api.identity-server-delegation.svc.cluster.local',
+ prod: 'https://auth-delegation-api.internal.innskra.island.is',
+ },
+ AUTH_DELEGATION_MACHINE_CLIENT_SCOPE: json([
+ '@island.is/auth/delegations/index:system',
+ ]),
+ USER_NOTIFICATION_APP_PROTOCOL: {
+ dev: 'is.island.app.dev',
+ staging: 'is.island.app.dev', // intentionally set to dev - see firebase setup
+ prod: 'is.island.app',
+ },
+ SERVICE_PORTAL_CLICK_ACTION_URL: 'https://island.is/minarsidur',
+})
+
export const userNotificationServiceSetup = (services: {
userProfileApi: ServiceBuilder<'service-portal-api'>
}): ServiceBuilder =>
@@ -27,27 +57,7 @@ export const userNotificationServiceSetup = (services: {
.db()
.command('node')
.args('--no-experimental-fetch', 'main.js')
- .env({
- MAIN_QUEUE_NAME,
- DEAD_LETTER_QUEUE_NAME,
- IDENTITY_SERVER_ISSUER_URL: {
- dev: 'https://identity-server.dev01.devland.is',
- staging: 'https://identity-server.staging01.devland.is',
- prod: 'https://innskra.island.is',
- },
- USER_PROFILE_CLIENT_URL: ref(
- (ctx) => `http://${ctx.svc(services.userProfileApi)}`,
- ),
- AUTH_DELEGATION_API_URL: {
- dev: 'http://web-services-auth-delegation-api.identity-server-delegation.svc.cluster.local',
- staging:
- 'http://web-services-auth-delegation-api.identity-server-delegation.svc.cluster.local',
- prod: 'https://auth-delegation-api.internal.innskra.island.is',
- },
- AUTH_DELEGATION_MACHINE_CLIENT_SCOPE: json([
- '@island.is/auth/delegations/index:system',
- ]),
- })
+ .env(getEnv(services))
.secrets({
FIREBASE_CREDENTIALS: `/k8s/${serviceName}/firestore-credentials`,
CONTENTFUL_ACCESS_TOKEN: `/k8s/${serviceName}/CONTENTFUL_ACCESS_TOKEN`,
@@ -94,7 +104,7 @@ export const userNotificationServiceSetup = (services: {
memory: '256Mi',
},
})
- .grantNamespaces('nginx-ingress-internal')
+ .grantNamespaces('nginx-ingress-internal', 'identity-server-delegation')
export const userNotificationWorkerSetup = (services: {
userProfileApi: ServiceBuilder
@@ -108,36 +118,13 @@ export const userNotificationWorkerSetup = (services: {
.db()
.migrations()
.env({
- MAIN_QUEUE_NAME,
- DEAD_LETTER_QUEUE_NAME,
+ ...getEnv(services),
EMAIL_REGION: 'eu-west-1',
- IDENTITY_SERVER_ISSUER_URL: {
- dev: 'https://identity-server.dev01.devland.is',
- staging: 'https://identity-server.staging01.devland.is',
- prod: 'https://innskra.island.is',
- },
- USER_PROFILE_CLIENT_URL: ref(
- (ctx) => `http://${ctx.svc(services.userProfileApi)}`,
- ),
- USER_NOTIFICATION_APP_PROTOCOL: {
- dev: 'is.island.app.dev',
- staging: 'is.island.app.dev', // intentionally set to dev - see firebase setup
- prod: 'is.island.app',
- },
CONTENTFUL_HOST: {
dev: 'preview.contentful.com',
staging: 'cdn.contentful.com',
prod: 'cdn.contentful.com',
},
- AUTH_DELEGATION_API_URL: {
- dev: 'http://web-services-auth-delegation-api.identity-server-delegation.svc.cluster.local',
- staging:
- 'http://web-services-auth-delegation-api.identity-server-delegation.svc.cluster.local',
- prod: 'https://auth-delegation-api.internal.innskra.island.is',
- },
- AUTH_DELEGATION_MACHINE_CLIENT_SCOPE: json([
- '@island.is/auth/delegations/index:system',
- ]),
})
.resources({
limits: {
diff --git a/apps/services/user-notification/migrations/20231117091125-create-user-notification-table.js b/apps/services/user-notification/migrations/20231117091125-create-user-notification-table.js
index 7fcf0db5fa49..c9b375397e73 100644
--- a/apps/services/user-notification/migrations/20231117091125-create-user-notification-table.js
+++ b/apps/services/user-notification/migrations/20231117091125-create-user-notification-table.js
@@ -47,7 +47,7 @@ module.exports = {
await queryInterface.addIndex('user_notification', ['message_id']) // Adding index
},
- down: async (queryInterface, Sequelize) => {
+ down: async (queryInterface) => {
await queryInterface.dropTable('user_notification')
},
}
diff --git a/apps/services/user-notification/migrations/20240227091126-add-sender-id.js b/apps/services/user-notification/migrations/20240227091126-add-sender-id.js
index 1f5a3d68742d..e301d77a0137 100644
--- a/apps/services/user-notification/migrations/20240227091126-add-sender-id.js
+++ b/apps/services/user-notification/migrations/20240227091126-add-sender-id.js
@@ -8,7 +8,7 @@ module.exports = {
})
},
- async down(queryInterface, Sequelize) {
+ async down(queryInterface) {
// Logic for reverting the changes
await queryInterface.removeColumn('user_notification', 'sender_id')
},
diff --git a/apps/services/user-notification/src/app/app.module.ts b/apps/services/user-notification/src/app/app.module.ts
index 2ed50e1a9bf3..0eb8c48110f6 100644
--- a/apps/services/user-notification/src/app/app.module.ts
+++ b/apps/services/user-notification/src/app/app.module.ts
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common'
import { NotificationsModule } from './modules/notifications/notifications.module'
-
import { SequelizeModule } from '@nestjs/sequelize'
-import { SequelizeConfigService } from './sequelizeConfig.service'
+
import { AuthConfig, AuthModule } from '@island.is/auth-nest-tools'
-import { environment } from '../environments/environment'
import {
ConfigModule,
IdsClientConfig,
@@ -14,6 +12,11 @@ import { NationalRegistryV3ClientConfig } from '@island.is/clients/national-regi
import { FeatureFlagConfig } from '@island.is/nest/feature-flags'
import { UserProfileClientConfig } from '@island.is/clients/user-profile'
import { AuthDelegationApiClientConfig } from '@island.is/clients/auth/delegation-api'
+
+import { SequelizeConfigService } from './sequelizeConfig.service'
+import { environment } from '../environments/environment'
+import { UserNotificationsConfig } from '../config'
+
@Module({
imports: [
AuthModule.register({
@@ -26,6 +29,7 @@ import { AuthDelegationApiClientConfig } from '@island.is/clients/auth/delegatio
ConfigModule.forRoot({
isGlobal: true,
load: [
+ UserNotificationsConfig,
XRoadConfig,
NationalRegistryV3ClientConfig,
FeatureFlagConfig,
diff --git a/apps/services/user-notification/src/app/modules/notifications/dto/createHnippNotification.dto.ts b/apps/services/user-notification/src/app/modules/notifications/dto/createHnippNotification.dto.ts
index a0ced4e31cda..1282e34434bd 100644
--- a/apps/services/user-notification/src/app/modules/notifications/dto/createHnippNotification.dto.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/dto/createHnippNotification.dto.ts
@@ -3,6 +3,21 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import { IsNationalId } from '@island.is/nest/core'
import { Type } from 'class-transformer'
+export class HnippNotificationOriginalRecipientDto {
+ @IsString()
+ @ApiProperty({ example: '1234567890' })
+ nationalId!: string
+
+ @IsString()
+ @ApiProperty()
+ name!: string
+
+ @IsString()
+ @ApiProperty()
+ @IsOptional()
+ subjectId?: string
+}
+
export class ArgumentDto {
@IsString()
@ApiProperty({ example: 'key' })
@@ -24,9 +39,9 @@ export class CreateHnippNotificationDto {
senderId?: string
@IsOptional()
- @IsString()
- @ApiPropertyOptional({ example: '1234567890' })
- onBehalfOf?: string
+ @Type(() => HnippNotificationOriginalRecipientDto)
+ @ApiPropertyOptional()
+ onBehalfOf?: HnippNotificationOriginalRecipientDto
@IsString()
@ApiProperty({ example: 'HNIPP.POSTHOLF.NEW_DOCUMENT' })
@@ -36,6 +51,7 @@ export class CreateHnippNotificationDto {
@Type(() => ArgumentDto)
@ValidateNested({ each: true })
@ApiProperty({
+ type: [ArgumentDto],
example: [
{ key: 'organization', value: 'Hnipp Test Crew' },
{ key: 'documentId', value: 'abcd-abcd-abcd-abcd' },
diff --git a/apps/services/user-notification/src/app/modules/notifications/dto/createNotification.dto.ts b/apps/services/user-notification/src/app/modules/notifications/dto/createNotification.dto.ts
index 6c83eb037a4e..d9a138aef544 100644
--- a/apps/services/user-notification/src/app/modules/notifications/dto/createNotification.dto.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/dto/createNotification.dto.ts
@@ -1,13 +1,13 @@
import { IsString } from 'class-validator'
import { ApiProperty } from '@nestjs/swagger'
-import { IsNationalId } from '@island.is/nest/core'
+import { IsPersonNationalId } from '@island.is/nest/core'
export class CreateNotificationDto {
@IsString()
@ApiProperty({ example: 'newDocumentMessage' })
type!: string
- @IsNationalId()
+ @IsPersonNationalId()
@ApiProperty({ example: '1234567890' })
recipient!: string
diff --git a/apps/services/user-notification/src/app/modules/notifications/dto/notification.dto.ts b/apps/services/user-notification/src/app/modules/notifications/dto/notification.dto.ts
index a73fb52e16e9..3436694fec98 100644
--- a/apps/services/user-notification/src/app/modules/notifications/dto/notification.dto.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/dto/notification.dto.ts
@@ -12,6 +12,7 @@ import {
IsBoolean,
} from 'class-validator'
import { Type } from 'class-transformer'
+import type { Locale } from '@island.is/shared/types'
export class ExtendedPaginationDto extends PaginationDto {
@IsOptional()
@@ -20,7 +21,7 @@ export class ExtendedPaginationDto extends PaginationDto {
type: 'string',
})
@IsString()
- locale!: string
+ locale!: Locale
}
class ArgItem {
@@ -106,7 +107,7 @@ export class RenderedNotificationDto {
@IsOptional()
clickAction?: string
- @ApiPropertyOptional({ example: '//inbox/document-uuid' })
+ @ApiPropertyOptional({ example: 'https://island.is/minarsidur/postholf' })
@IsString()
@IsOptional()
clickActionUrl?: string
diff --git a/apps/services/user-notification/src/app/modules/notifications/me-notifications.controller.ts b/apps/services/user-notification/src/app/modules/notifications/me-notifications.controller.ts
index 6e311b2fb7b4..ed05748a34df 100644
--- a/apps/services/user-notification/src/app/modules/notifications/me-notifications.controller.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/me-notifications.controller.ts
@@ -19,6 +19,7 @@ import {
ScopesGuard,
} from '@island.is/auth-nest-tools'
import type { User } from '@island.is/auth-nest-tools'
+import type { Locale } from '@island.is/shared/types'
import {
UpdateNotificationDto,
@@ -83,7 +84,7 @@ export class MeNotificationsController {
findOne(
@CurrentUser() user: User,
@Param('id') id: number,
- @Query('locale') locale: string,
+ @Query('locale') locale?: Locale,
): Promise {
return this.notificationService.findOne(user, id, locale)
}
@@ -110,7 +111,7 @@ export class MeNotificationsController {
@CurrentUser() user: User,
@Param('id') id: number,
@Body() updateNotificationDto: UpdateNotificationDto,
- @Query('locale') locale: string,
+ @Query('locale') locale?: Locale,
): Promise {
return this.notificationService.update(
user,
diff --git a/apps/services/user-notification/src/app/modules/notifications/messageProcessor.service.ts b/apps/services/user-notification/src/app/modules/notifications/messageProcessor.service.ts
index bb96eff6be2d..05c6606dec30 100644
--- a/apps/services/user-notification/src/app/modules/notifications/messageProcessor.service.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/messageProcessor.service.ts
@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common'
import { Notification } from './types'
-import { UserProfileDto } from '@island.is/clients/user-profile'
import { NotificationsService } from './notifications.service'
import { CreateHnippNotificationDto } from './dto/createHnippNotification.dto'
-
+import type { Locale } from '@island.is/shared/types'
export const APP_PROTOCOL = Symbol('APP_PROTOCOL')
export interface MessageProcessorServiceConfig {
@@ -16,11 +15,11 @@ export class MessageProcessorService {
async convertToNotification(
message: CreateHnippNotificationDto,
- profile: UserProfileDto,
+ locale?: Locale,
): Promise {
const template = await this.notificationsService.getTemplate(
message.templateId,
- profile.locale,
+ locale,
)
const notification = this.notificationsService.formatArguments(
message.args,
diff --git a/apps/services/user-notification/src/app/modules/notifications/notifications.controller.ts b/apps/services/user-notification/src/app/modules/notifications/notifications.controller.ts
index bab55d6d027e..aeabcd275675 100644
--- a/apps/services/user-notification/src/app/modules/notifications/notifications.controller.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/notifications.controller.ts
@@ -1,37 +1,30 @@
import { InjectQueue, QueueService } from '@island.is/message-queue'
import { CacheInterceptor } from '@nestjs/cache-manager'
import {
- Inject,
Body,
+ Controller,
Get,
+ Inject,
Param,
+ Post,
Query,
UseInterceptors,
Version,
- VERSION_NEUTRAL,
- Controller,
- Post,
- HttpCode,
} from '@nestjs/common'
-import {
- ApiOkResponse,
- ApiBody,
- ApiExtraModels,
- getSchemaPath,
- ApiOperation,
-} from '@nestjs/swagger'
+import { ApiExtraModels, ApiTags } from '@nestjs/swagger'
import type { Logger } from '@island.is/logging'
import { LOGGER_PROVIDER } from '@island.is/logging'
+import { Documentation } from '@island.is/nest/swagger'
+
import { CreateNotificationDto } from './dto/createNotification.dto'
import { CreateNotificationResponse } from './dto/createNotification.response'
-
import { CreateHnippNotificationDto } from './dto/createHnippNotification.dto'
-import { Documentation } from '@island.is/nest/swagger'
import { HnippTemplate } from './dto/hnippTemplate.response'
-
import { NotificationsService } from './notifications.service'
+import type { Locale } from '@island.is/shared/types'
@Controller('notifications')
+@ApiTags('notifications')
@ApiExtraModels(CreateNotificationDto)
@UseInterceptors(CacheInterceptor)
export class NotificationsController {
@@ -41,42 +34,6 @@ export class NotificationsController {
@InjectQueue('notifications') private queue: QueueService,
) {}
- // redirecting legacy endpoint to new one with fixed values
- @ApiBody({
- schema: {
- type: 'object',
- oneOf: [{ $ref: getSchemaPath(CreateNotificationDto) }],
- },
- })
- @ApiOkResponse({ type: CreateNotificationResponse })
- @ApiOperation({ deprecated: true })
- @HttpCode(201)
- @Post()
- @Version(VERSION_NEUTRAL)
- async createDeprecatedNotification(
- @Body() body: CreateNotificationDto,
- ): Promise {
- this.logger.info('Creating notification', {
- recipient: body.recipient,
- organization: body.organization,
- documentId: body.documentId,
- })
- return this.createHnippNotification({
- recipient: body.recipient,
- templateId: 'HNIPP.POSTHOLF.NEW_DOCUMENT',
- args: [
- {
- key: 'organization',
- value: body.organization,
- },
- {
- key: 'documentId',
- value: body.documentId,
- },
- ],
- })
- }
-
@Documentation({
summary: 'Fetches all notification templates',
includeNoContentResponse: true,
@@ -86,7 +43,8 @@ export class NotificationsController {
locale: {
required: false,
type: 'string',
- example: 'is-IS',
+ description: 'locale',
+ example: 'en',
},
},
},
@@ -94,9 +52,9 @@ export class NotificationsController {
@Get('/templates')
@Version('1')
async getNotificationTemplates(
- @Query('locale') locale: string,
+ @Query('locale') locale?: Locale,
): Promise {
- this.logger.info(`Fetching hnipp template for locale: ${locale}`)
+ this.logger.info(`Fetching hnipp templates for locale: ${locale}`)
return await this.notificationsService.getTemplates(locale)
}
@@ -105,13 +63,6 @@ export class NotificationsController {
includeNoContentResponse: true,
response: { status: 200, type: HnippTemplate },
request: {
- query: {
- locale: {
- required: false,
- type: 'string',
- example: 'is-IS',
- },
- },
params: {
templateId: {
type: 'string',
@@ -119,6 +70,14 @@ export class NotificationsController {
example: 'HNIPP.POSTHOLF.NEW_DOCUMENT',
},
},
+ query: {
+ locale: {
+ required: false,
+ type: 'string',
+ description: 'locale',
+ example: 'en',
+ },
+ },
},
})
@Get('/template/:templateId')
@@ -126,7 +85,7 @@ export class NotificationsController {
async getNotificationTemplate(
@Param('templateId')
templateId: string,
- @Query('locale') locale: string,
+ @Query('locale') locale: Locale,
): Promise {
return await this.notificationsService.getTemplate(templateId, locale)
}
@@ -142,20 +101,15 @@ export class NotificationsController {
@Body() body: CreateHnippNotificationDto,
): Promise {
await this.notificationsService.validate(body.templateId, body.args)
-
const id = await this.queue.add(body)
-
- const records: Record = {}
-
+ const flattenedArgs: Record = {}
for (const arg of body.args) {
- records[arg.key] = arg.value
+ flattenedArgs[arg.key] = arg.value
}
-
this.logger.info('Message queued', {
messageId: id,
- ...records,
- templateId: body.templateId,
- recipient: body.recipient,
+ ...flattenedArgs,
+ ...body,
})
return {
diff --git a/apps/services/user-notification/src/app/modules/notifications/notifications.module.ts b/apps/services/user-notification/src/app/modules/notifications/notifications.module.ts
index 7eff82393aeb..5bf6cedf22cf 100644
--- a/apps/services/user-notification/src/app/modules/notifications/notifications.module.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/notifications.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'
import { SequelizeModule } from '@nestjs/sequelize'
import { CacheModule } from '@nestjs/cache-manager'
+import { ConfigType } from '@nestjs/config'
import * as firebaseAdmin from 'firebase-admin'
import { NationalRegistryV3ClientModule } from '@island.is/clients/national-registry-v3'
@@ -23,11 +24,13 @@ import { NotificationDispatchService } from './notificationDispatch.service'
import {
IS_RUNNING_AS_WORKER,
NotificationsWorkerService,
+ SERVICE_PORTAL_CLICK_ACTION_URL,
} from './notificationsWorker/notificationsWorker.service'
import {
APP_PROTOCOL,
MessageProcessorService,
} from './messageProcessor.service'
+import { UserNotificationsConfig } from '../../../config'
@Module({
exports: [NotificationsService],
@@ -35,7 +38,7 @@ import {
SequelizeModule.forFeature([Notification]),
CacheModule.register({
ttl: 60 * 10 * 1000, // 10 minutes
- max: 100, // 100 items max
+ max: 1000, // 1000 items max
}),
LoggingModule,
CmsTranslationsModule,
@@ -67,22 +70,33 @@ import {
MessageProcessorService,
{
provide: FIREBASE_PROVIDER,
- useFactory: () =>
+ useFactory: (config: ConfigType) =>
process.env.INIT_SCHEMA === 'true'
? {}
: firebaseAdmin.initializeApp({
credential: firebaseAdmin.credential.cert(
- JSON.parse(environment.firebaseCredentials),
+ JSON.parse(config.firebaseCredentials),
),
}),
+ inject: [UserNotificationsConfig.KEY],
},
{
provide: IS_RUNNING_AS_WORKER,
- useValue: environment.isWorker,
+ useFactory: (config: ConfigType) =>
+ config.isWorker,
+ inject: [UserNotificationsConfig.KEY],
},
{
provide: APP_PROTOCOL,
- useValue: environment.appProtocol,
+ useFactory: (config: ConfigType) =>
+ config.appProtocol,
+ inject: [UserNotificationsConfig.KEY],
+ },
+ {
+ provide: SERVICE_PORTAL_CLICK_ACTION_URL,
+ useFactory: (config: ConfigType) =>
+ config.servicePortalClickActionUrl,
+ inject: [UserNotificationsConfig.KEY],
},
],
})
diff --git a/apps/services/user-notification/src/app/modules/notifications/notifications.service.ts b/apps/services/user-notification/src/app/modules/notifications/notifications.service.ts
index dc338ffcc318..4fe7ff7ea453 100644
--- a/apps/services/user-notification/src/app/modules/notifications/notifications.service.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/notifications.service.ts
@@ -27,6 +27,8 @@ import {
UnseenNotificationsCountDto,
UnreadNotificationsCountDto,
} from './dto/notification.dto'
+import type { Locale } from '@island.is/shared/types'
+import { mapToContentfulLocale, mapToLocale } from './utils'
const ACCESS_TOKEN = process.env.CONTENTFUL_ACCESS_TOKEN
const CONTENTFUL_GQL_ENDPOINT =
@@ -44,10 +46,9 @@ const ALLOWED_REPLACE_PROPS: Array = [
'clickActionUrl',
]
-/**
- * Finds {{key}} in string
- */
-const ARG_REPLACE_REGEX = new RegExp(/{{[^{}]*}}/)
+type SenderOrganization = {
+ title: string | undefined
+}
@Injectable()
export class NotificationsService {
@@ -59,18 +60,108 @@ export class NotificationsService {
private readonly notificationModel: typeof Notification,
) {}
- private async formatAndMapNotification(
+ async performGraphQLRequest(query: string) {
+ try {
+ const response = await axios.post(
+ CONTENTFUL_GQL_ENDPOINT,
+ { query },
+ {
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${ACCESS_TOKEN}`,
+ },
+ },
+ )
+ return response.data
+ } catch (error) {
+ if (error.response) {
+ throw new BadRequestException(error.response.data)
+ } else {
+ this.logger.error('GraphQL Request Failed', { error })
+ throw new InternalServerErrorException('Internal Server Error')
+ }
+ }
+ }
+
+ async getSenderOrganization(
+ senderId: string,
+ locale?: Locale,
+ ): Promise {
+ locale = mapToLocale(locale as Locale)
+ const cacheKey = `org-${senderId}-${locale}`
+ const cachedOrganization = await this.cacheManager.get(
+ cacheKey,
+ )
+ if (cachedOrganization) {
+ this.logger.info(`Cache HIT for: ${cacheKey}`, cachedOrganization)
+ return cachedOrganization
+ } else {
+ this.logger.warn(`Cache MISS for: ${cacheKey}`)
+ }
+ const contentfulOrganizationQuery = `{
+ organizationCollection(where: {kennitala: "${senderId}"}, locale: "${mapToContentfulLocale(
+ locale,
+ )}") {
+ items {
+ title
+ }
+ }
+ }`
+ const res = await this.performGraphQLRequest(contentfulOrganizationQuery)
+ const organizationTitle =
+ res.data.organizationCollection.items?.[0]?.title ?? undefined
+ const result: SenderOrganization = { title: organizationTitle }
+
+ if (!organizationTitle) {
+ this.logger.warn(`Organization title not found for senderId: ${senderId}`)
+ }
+ // always store the result in cache wether it is found or not to avoid multiple requests
+ await this.cacheManager.set(cacheKey, result)
+ return result
+ }
+
+ async formatAndMapNotification(
notification: Notification,
templateId: string,
- locale: string,
+ locale?: Locale,
template?: HnippTemplate,
): Promise {
+ locale = mapToLocale(locale as Locale)
try {
// If template is not provided, fetch it
if (!template) {
template = await this.getTemplate(templateId, locale)
}
+ // check for organization argument to fetch translated organization title
+ const organizationArg = notification.args.find(
+ (arg) => arg.key === 'organization',
+ )
+
+ // if senderId is set and args contains organization, fetch organizationtitle from senderId
+ if (notification.senderId && organizationArg) {
+ try {
+ const sender = await this.getSenderOrganization(
+ notification.senderId,
+ locale,
+ )
+
+ if (sender.title) {
+ organizationArg.value = sender.title
+ } else {
+ this.logger.warn('title not found ', {
+ senderId: notification.senderId,
+ locale,
+ })
+ }
+ } catch (error) {
+ this.logger.error('error trying to get org title', {
+ senderId: notification.senderId,
+ locale,
+ })
+ }
+ }
+
// Format the template with arguments from the notification
const formattedTemplate = this.formatArguments(
notification.args,
@@ -98,30 +189,22 @@ export class NotificationsService {
}
}
- async addToCache(key: string, item: object) {
- return await this.cacheManager.set(key, item)
- }
-
- async getFromCache(key: string) {
- return await this.cacheManager.get(key)
- }
-
- mapLocale(locale?: string | null | undefined): string {
- return locale === 'en' ? locale : 'is-IS'
- }
+ async getTemplates(locale?: Locale): Promise {
+ locale = mapToLocale(locale as Locale)
+ const cacheKey = `templates-${locale}`
- async getTemplates(
- locale?: string | null | undefined,
- ): Promise {
- const mappedLocale = this.mapLocale(locale)
-
- this.logger.info(
- 'Fetching templates from Contentful GQL for locale: ' + mappedLocale,
+ // Try to retrieve the templates from cache first
+ const cachedTemplates = await this.cacheManager.get(
+ cacheKey,
)
+ if (cachedTemplates) {
+ this.logger.info(`Cache hit for templates: ${cacheKey}`)
+ return cachedTemplates
+ }
- const contentfulHnippTemplatesQuery = {
- query: `{
- hnippTemplateCollection(locale: "${mappedLocale}") {
+ // GraphQL query to fetch all templates for the specified locale
+ const contentfulTemplatesQuery = `{
+ hnippTemplateCollection(locale: "${mapToContentfulLocale(locale)}") {
items {
templateId
notificationTitle
@@ -134,59 +217,76 @@ export class NotificationsService {
args
}
}
- }`,
- }
+ }`
- const res = await axios
- .post(CONTENTFUL_GQL_ENDPOINT, contentfulHnippTemplatesQuery, {
- headers: {
- 'content-type': 'application/json',
- authorization: `Bearer ${ACCESS_TOKEN}`,
- },
- })
- .then((response) => {
- for (const item of response.data.data.hnippTemplateCollection.items) {
- // contentful returns null for empty arrays
- if (item.args == null) item.args = []
- }
- return response.data
- })
- .catch((error) => {
- if (error.response) {
- throw new BadRequestException(error.response.data)
- } else {
- throw new BadRequestException('Bad Request')
- }
- })
- return res.data.hnippTemplateCollection.items
+ try {
+ const res = await this.performGraphQLRequest(contentfulTemplatesQuery)
+ const templates = res.data.hnippTemplateCollection.items
+
+ if (templates.length === 0) {
+ this.logger.warn(`No templates found for locale: ${locale}`)
+ return []
+ }
+
+ // Cache the fetched templates before returning
+ await this.cacheManager.set(cacheKey, templates)
+ return templates
+ } catch (error) {
+ this.logger.error('Error fetching templates:', { locale, error })
+ throw error // Rethrow the caught error
+ }
}
async getTemplate(
templateId: string,
- locale?: string | null | undefined,
+ locale?: Locale,
): Promise {
- locale = this.mapLocale(locale)
- //check cache
- const cacheKey = `${templateId}-${locale}`
- const cachedTemplate = await this.getFromCache(cacheKey)
+ locale = mapToLocale(locale as Locale)
+ const cacheKey = `template-${templateId}-${locale}`
+ // Try to retrieve the template from cache first
+ const cachedTemplate = await this.cacheManager.get(cacheKey)
if (cachedTemplate) {
- this.logger.info(`cache hit for: ${cacheKey}`)
-
- return cachedTemplate as HnippTemplate
+ this.logger.info(`Cache hit for template: ${cacheKey}`)
+ return cachedTemplate
}
- try {
- for (const template of await this.getTemplates(locale)) {
- if (template.templateId == templateId) {
- await this.addToCache(cacheKey, template)
- return template
+ // Query to fetch a specific template by templateId
+ const contentfulTemplateQuery = `{
+ hnippTemplateCollection(where: {templateId: "${templateId}"}, locale: "${mapToContentfulLocale(
+ locale,
+ )}") {
+ items {
+ templateId
+ notificationTitle
+ notificationBody
+ notificationDataCopy
+ clickAction
+ clickActionWeb
+ clickActionUrl
+ category
+ args
}
}
+ }`
+
+ try {
+ const res = await this.performGraphQLRequest(contentfulTemplateQuery)
+ const template =
+ res.data.hnippTemplateCollection.items.length > 0
+ ? res.data.hnippTemplateCollection.items[0]
+ : null
+
+ if (!template) {
+ throw new NotFoundException(`Template not found for ID: ${templateId}`)
+ }
- throw new NotFoundException(`Template: ${templateId} not found`)
- } catch {
- throw new NotFoundException(`Template: ${templateId} not found`)
+ // Cache the fetched template before returning
+ await this.cacheManager.set(cacheKey, template)
+ return template
+ } catch (error) {
+ this.logger.error('Error fetching template:', { templateId, error })
+ throw error // Rethrow the caught error
}
}
@@ -231,42 +331,39 @@ export class NotificationsService {
}
}
+ /**
+ * Replaces the placeholders in the template with the values provided in the request body
+ */
formatArguments(args: ArgumentDto[], template: HnippTemplate): HnippTemplate {
- if (args.length > 0) {
- Object.keys(template).forEach((key) => {
+ // Deep clone the template to avoid modifying the original
+ const formattedTemplate = JSON.parse(JSON.stringify(template))
+
+ args.forEach((arg) => {
+ Object.keys(formattedTemplate).forEach((key) => {
const templateKey = key as keyof HnippTemplate
if (
ALLOWED_REPLACE_PROPS.includes(templateKey) &&
- templateKey !== 'args'
+ typeof formattedTemplate[templateKey] === 'string'
) {
- const value = template[templateKey] as string
-
- if (value && ARG_REPLACE_REGEX.test(value)) {
- for (const arg of args) {
- const regexTarget = new RegExp(`{{${arg.key}}}`, 'g')
- const newValue = value.replace(regexTarget, arg.value)
- if (newValue !== value) {
- // finds {{key}} in string and replace with value
- template[templateKey] = value.replace(regexTarget, arg.value)
- break
- }
- }
- }
+ formattedTemplate[templateKey] = formattedTemplate[
+ templateKey
+ ].replace(new RegExp(`{{${arg.key}}}`, 'g'), arg.value)
}
})
- }
+ })
- return template
+ return formattedTemplate
}
async findOne(
user: User,
id: number,
- locale: string,
+ locale?: Locale,
): Promise {
+ locale = mapToLocale(locale as Locale)
const notification = await this.notificationModel.findOne({
- where: { id: id, recipient: user.nationalId },
+ where: { id, recipient: user.nationalId },
})
if (!notification) {
@@ -284,7 +381,8 @@ export class NotificationsService {
user: User,
query: ExtendedPaginationDto,
): Promise {
- const templates = await this.getTemplates(query.locale)
+ const locale = mapToLocale(query.locale as Locale)
+ const templates = await this.getTemplates(locale)
const paginatedListResponse = await paginate({
Model: this.notificationModel,
limit: query.limit || 10,
@@ -305,7 +403,7 @@ export class NotificationsService {
return this.formatAndMapNotification(
notification,
notification.templateId,
- query.locale,
+ locale,
template,
)
} else {
@@ -324,8 +422,9 @@ export class NotificationsService {
user: User,
id: number,
updateNotificationDto: UpdateNotificationDto,
- locale: string,
+ locale?: Locale,
): Promise {
+ locale = mapToLocale(locale as Locale)
const [numberOfAffectedRows, [updatedNotification]] =
await this.notificationModel.update(updateNotificationDto, {
where: {
diff --git a/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/mocks.ts b/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/mocks.ts
index 35ee0dce38be..f0c7a6c0a934 100644
--- a/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/mocks.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/mocks.ts
@@ -1,13 +1,22 @@
+import faker from 'faker'
+
import { UserProfileDto } from '@island.is/clients/user-profile'
import { createNationalId } from '@island.is/testing/fixtures'
import { DelegationRecordDTO } from '@island.is/clients/auth/delegation-api'
import { Features } from '@island.is/feature-flags'
import type { User } from '@island.is/auth-nest-tools'
+
import { HnippTemplate } from '../dto/hnippTemplate.response'
export const mockFullName = 'mockFullName'
+export const delegationSubjectId = 'delegation-subject-id'
-export const userWithDelegations: UserProfileDto = {
+interface MockUserProfileDto extends UserProfileDto {
+ name: string
+}
+
+export const userWithDelegations: MockUserProfileDto = {
+ name: 'userWithDelegations',
nationalId: createNationalId('person'),
mobilePhoneNumber: '1234567',
email: 'email@email.com',
@@ -15,9 +24,11 @@ export const userWithDelegations: UserProfileDto = {
mobilePhoneNumberVerified: true,
documentNotifications: true,
emailNotifications: true,
+ isRestricted: false,
}
-export const userWithDelegations2: UserProfileDto = {
+export const userWithDelegations2: MockUserProfileDto = {
+ name: 'userWithDelegations2',
nationalId: createNationalId('person'),
mobilePhoneNumber: '1234567',
email: 'email5@email.com',
@@ -25,9 +36,11 @@ export const userWithDelegations2: UserProfileDto = {
mobilePhoneNumberVerified: true,
documentNotifications: true,
emailNotifications: true,
+ isRestricted: false,
}
-export const userWitNoDelegations: UserProfileDto = {
+export const userWithNoDelegations: MockUserProfileDto = {
+ name: 'userWithNoDelegations',
nationalId: createNationalId('person'),
mobilePhoneNumber: '1234567',
email: 'email1@email.com',
@@ -35,18 +48,22 @@ export const userWitNoDelegations: UserProfileDto = {
mobilePhoneNumberVerified: true,
documentNotifications: true,
emailNotifications: true,
+ isRestricted: false,
}
-export const userWithEmailNotificationsDisabled: UserProfileDto = {
+export const userWithEmailNotificationsDisabled: MockUserProfileDto = {
+ name: 'userWithEmailNotificationsDisabled',
nationalId: createNationalId('person'),
mobilePhoneNumber: '1234567',
emailVerified: true,
mobilePhoneNumberVerified: true,
documentNotifications: true,
emailNotifications: false,
+ isRestricted: false,
}
-export const userWithDocumentNotificationsDisabled: UserProfileDto = {
+export const userWithDocumentNotificationsDisabled: MockUserProfileDto = {
+ name: 'userWithDocumentNotificationsDisabled',
nationalId: createNationalId('person'),
mobilePhoneNumber: '1234567',
email: 'email2@email.com',
@@ -54,9 +71,11 @@ export const userWithDocumentNotificationsDisabled: UserProfileDto = {
mobilePhoneNumberVerified: true,
documentNotifications: false,
emailNotifications: true,
+ isRestricted: false,
}
-export const userWithFeatureFlagDisabled: UserProfileDto = {
+export const userWithFeatureFlagDisabled: MockUserProfileDto = {
+ name: 'userWithFeatureFlagDisabled',
nationalId: createNationalId('person'),
mobilePhoneNumber: '1234567',
email: 'email3@email.com',
@@ -64,32 +83,46 @@ export const userWithFeatureFlagDisabled: UserProfileDto = {
mobilePhoneNumberVerified: true,
documentNotifications: true,
emailNotifications: true,
+ isRestricted: false,
}
-export const userWithSendToDelegationsFeatureFlagDisabled: UserProfileDto = {
- nationalId: createNationalId('person'),
- mobilePhoneNumber: '1234567',
- email: 'email4@email.com',
- emailVerified: true,
- mobilePhoneNumberVerified: true,
- documentNotifications: true,
- emailNotifications: true,
-}
-
-export const mockHnippTemplate: HnippTemplate = {
- templateId: 'HNIPP.DEMO.ID',
- notificationTitle: 'Demo title ',
- notificationBody: 'Demo body {{arg1}}',
- notificationDataCopy: 'Demo data copy',
- clickAction: 'Demo click action {{arg2}}',
- category: 'Demo category',
- args: ['arg1', 'arg2'],
-}
+export const userWithSendToDelegationsFeatureFlagDisabled: MockUserProfileDto =
+ {
+ name: 'userWithSendToDelegationsFeatureFlagDisabled',
+ nationalId: createNationalId('person'),
+ mobilePhoneNumber: '1234567',
+ email: 'email4@email.com',
+ emailVerified: true,
+ mobilePhoneNumberVerified: true,
+ documentNotifications: true,
+ emailNotifications: true,
+ isRestricted: false,
+ }
-const userProfiles = [
+export const mockTemplateId = 'HNIPP.DEMO.ID'
+
+export const getMockHnippTemplate = ({
+ templateId = mockTemplateId,
+ notificationTitle = 'Demo title ',
+ notificationBody = 'Demo body {{arg1}}',
+ notificationDataCopy = 'Demo data copy',
+ clickActionUrl = 'https://island.is/minarsidur/postholf',
+ category = 'Demo category',
+ args = ['arg1', 'arg2'],
+}: Partial): HnippTemplate => ({
+ templateId,
+ notificationTitle,
+ notificationBody,
+ notificationDataCopy,
+ clickActionUrl,
+ category,
+ args,
+})
+
+export const userProfiles = [
userWithDelegations,
userWithDelegations2,
- userWitNoDelegations,
+ userWithNoDelegations,
userWithEmailNotificationsDisabled,
userWithDocumentNotificationsDisabled,
userWithFeatureFlagDisabled,
@@ -100,44 +133,33 @@ const delegations: Record = {
[userWithDelegations.nationalId]: [
{
fromNationalId: userWithDelegations.nationalId,
- toNationalId: userWitNoDelegations.nationalId,
+ toNationalId: userWithNoDelegations.nationalId,
+ subjectId: null, // test that 3rd party login is not used if subjectId is null
},
],
[userWithDelegations2.nationalId]: [
{
fromNationalId: userWithDelegations2.nationalId,
toNationalId: userWithDelegations.nationalId,
+ subjectId: delegationSubjectId,
},
],
[userWithSendToDelegationsFeatureFlagDisabled.nationalId]: [
{
fromNationalId: userWithSendToDelegationsFeatureFlagDisabled.nationalId,
- toNationalId: userWitNoDelegations.nationalId,
+ toNationalId: userWithNoDelegations.nationalId,
+ subjectId: faker.datatype.uuid(),
},
],
}
export class MockDelegationsService {
delegationsControllerGetDelegationRecords({
- xQueryFromNationalId,
- }: {
- xQueryFromNationalId: string
- }) {
- return { data: delegations[xQueryFromNationalId] ?? [] }
- }
-}
-
-export class MockV2UsersApi {
- userProfileControllerFindUserProfile({
- xParamNationalId,
+ xQueryNationalId,
}: {
- xParamNationalId: string
+ xQueryNationalId: string
}) {
- return Promise.resolve(
- userProfiles.find(
- (u) => u.nationalId === xParamNationalId,
- ) as UserProfileDto,
- )
+ return { data: delegations[xQueryNationalId] ?? [] }
}
}
@@ -159,9 +181,11 @@ export class MockFeatureFlagService {
}
export class MockNationalRegistryV3ClientService {
- getName() {
+ getName(nationalId: string) {
+ const user = userProfiles.find((u) => u.nationalId === nationalId)
+
return {
- fulltNafn: mockFullName,
+ fulltNafn: user?.name ?? mockFullName,
}
}
}
diff --git a/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/notificationsWorker.service.spec.ts b/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/notificationsWorker.service.spec.ts
index 501c79b570e0..ece4ab7d21b6 100644
--- a/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/notificationsWorker.service.spec.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/notificationsWorker.service.spec.ts
@@ -4,7 +4,7 @@ import { INestApplication, Type } from '@nestjs/common'
import { TestingModuleBuilder } from '@nestjs/testing'
import { testServer, truncate, useDatabase } from '@island.is/testing/nest'
-import { V2UsersApi } from '@island.is/clients/user-profile'
+import { UserProfileDto, V2UsersApi } from '@island.is/clients/user-profile'
import { getQueueServiceToken, QueueService } from '@island.is/message-queue'
import { FeatureFlagService } from '@island.is/nest/feature-flags'
import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3'
@@ -18,23 +18,50 @@ import { SequelizeConfigService } from '../../../sequelizeConfig.service'
import {
MockDelegationsService,
MockFeatureFlagService,
- mockFullName,
- mockHnippTemplate,
MockNationalRegistryV3ClientService,
- MockV2UsersApi,
userWithDelegations,
userWithDelegations2,
userWithDocumentNotificationsDisabled,
userWithEmailNotificationsDisabled,
userWithFeatureFlagDisabled,
userWithSendToDelegationsFeatureFlagDisabled,
- userWitNoDelegations,
+ getMockHnippTemplate,
+ mockTemplateId,
+ delegationSubjectId,
+ userWithNoDelegations,
+ userProfiles,
} from './mocks'
import { wait } from './helpers'
import { Notification } from '../notification.model'
import { FIREBASE_PROVIDER } from '../../../../constants'
import { NotificationsService } from '../notifications.service'
+const workingHoursDelta = 1000 * 60 * 60 // 1 hour
+const insideWorkingHours = new Date(2021, 1, 1, 9, 0, 0)
+const outsideWorkingHours = new Date(2021, 1, 1, 7, 0, 0)
+
+export const MockV2UsersApi = {
+ userProfileControllerFindUserProfile: jest.fn(
+ ({ xParamNationalId }: { xParamNationalId: string }) => {
+ return Promise.resolve(
+ userProfiles.find(
+ (u) => u.nationalId === xParamNationalId,
+ ) as UserProfileDto,
+ )
+ },
+ ),
+
+ userProfileControllerGetActorProfile: jest.fn(
+ ({ xParamToNationalId }: { xParamToNationalId: string }) => {
+ return Promise.resolve(
+ userProfiles.find(
+ (u) => u.nationalId === xParamToNationalId,
+ ) as UserProfileDto,
+ )
+ },
+ ),
+}
+
describe('NotificationsWorkerService', () => {
let app: INestApplication
let sequelize: Sequelize
@@ -43,8 +70,7 @@ describe('NotificationsWorkerService', () => {
let queue: QueueService
let notificationModel: typeof Notification
let notificationsService: NotificationsService
-
- const insideWorkingHours = new Date(2021, 1, 1, 10, 0, 0)
+ let userProfileApi: V2UsersApi
beforeAll(async () => {
app = await testServer({
@@ -58,7 +84,7 @@ describe('NotificationsWorkerService', () => {
.overrideProvider(FeatureFlagService)
.useClass(MockFeatureFlagService)
.overrideProvider(V2UsersApi)
- .useClass(MockV2UsersApi)
+ .useValue(MockV2UsersApi)
.overrideProvider(IS_RUNNING_AS_WORKER)
.useValue(true)
.overrideProvider(FIREBASE_PROVIDER)
@@ -68,12 +94,6 @@ describe('NotificationsWorkerService', () => {
],
})
- // ensure tests always work by setting time to 10 AM (working hour)
- jest.useFakeTimers({
- advanceTimers: 10,
- now: insideWorkingHours,
- })
-
sequelize = await app.resolve(getConnectionToken() as Type)
notificationDispatch = app.get(
NotificationDispatchService,
@@ -82,9 +102,16 @@ describe('NotificationsWorkerService', () => {
queue = app.get(getQueueServiceToken('notifications'))
notificationModel = app.get(getModelToken(Notification))
notificationsService = app.get(NotificationsService)
+ userProfileApi = app.get(V2UsersApi)
})
beforeEach(async () => {
+ // ensure tests always work by setting time to 10 AM (working hour)
+ jest.useFakeTimers({
+ advanceTimers: true,
+ now: insideWorkingHours,
+ })
+
jest.clearAllMocks()
jest
@@ -97,7 +124,7 @@ describe('NotificationsWorkerService', () => {
jest
.spyOn(notificationsService, 'getTemplate')
- .mockReturnValue(Promise.resolve(mockHnippTemplate))
+ .mockReturnValue(Promise.resolve(getMockHnippTemplate({})))
})
afterAll(async () => {
@@ -109,9 +136,9 @@ describe('NotificationsWorkerService', () => {
})
const addToQueue = async (recipient: string) => {
- await queue.add({
+ const messageId = await queue.add({
recipient,
- templateId: mockHnippTemplate.templateId,
+ templateId: mockTemplateId,
args: [{ key: 'organization', value: 'Test Crew' }],
})
@@ -127,9 +154,20 @@ describe('NotificationsWorkerService', () => {
1,
expect.objectContaining({
to: expect.objectContaining({
- name: mockFullName,
+ name: userWithDelegations.name,
address: userWithDelegations.email,
}),
+ // email body should have a call-to-action button
+ template: expect.objectContaining({
+ body: expect.arrayContaining([
+ expect.objectContaining({
+ component: 'Button',
+ context: expect.objectContaining({
+ href: 'https://island.is/minarsidur/postholf',
+ }),
+ }),
+ ]),
+ }),
}),
)
@@ -138,8 +176,19 @@ describe('NotificationsWorkerService', () => {
2,
expect.objectContaining({
to: expect.objectContaining({
- name: mockFullName,
- address: userWitNoDelegations.email,
+ name: userWithDelegations.name, // should use the original recipient name
+ address: userWithNoDelegations.email,
+ }),
+ // should not have 3rd party login - because subjectId is null for the delegation between userWithDelegations and userWitNoDelegations
+ template: expect.objectContaining({
+ body: expect.arrayContaining([
+ expect.objectContaining({
+ component: 'Button',
+ context: expect.objectContaining({
+ href: 'https://island.is/minarsidur/postholf',
+ }),
+ }),
+ ]),
}),
}),
)
@@ -157,6 +206,24 @@ describe('NotificationsWorkerService', () => {
// should write the messages to db
const messages = await notificationModel.findAll()
expect(messages).toHaveLength(2)
+
+ // should have gotten user profile for primary recipient
+ expect(
+ userProfileApi.userProfileControllerFindUserProfile,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ xParamNationalId: userWithDelegations.nationalId,
+ }),
+ )
+
+ // should have gotten actor profile for delegation holder
+ expect(
+ userProfileApi.userProfileControllerGetActorProfile,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ xParamToNationalId: userWithNoDelegations.nationalId,
+ }),
+ )
})
it('should not send email or push notifications to delegation holders if recipient is a delegation holder (test correct propagation of emails to delegation holders)', async () => {
@@ -171,9 +238,76 @@ describe('NotificationsWorkerService', () => {
1,
expect.objectContaining({
to: expect.objectContaining({
- name: mockFullName,
+ name: userWithDelegations2.name,
+ address: userWithDelegations2.email,
+ }),
+ template: expect.objectContaining({
+ body: expect.arrayContaining([
+ expect.objectContaining({
+ component: 'Button',
+ context: expect.objectContaining({
+ href: 'https://island.is/minarsidur/postholf',
+ }),
+ }),
+ ]),
+ }),
+ }),
+ )
+
+ // should send email to delegation recipient
+ expect(emailService.sendEmail).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ to: expect.objectContaining({
+ name: userWithDelegations2.name, // should use the original recipient name
+ address: userWithDelegations.email,
+ }),
+ // should use 3rd party login - because subjectId is not null for the delegation between userWithDelegations2 and userWithDelegations
+ template: expect.objectContaining({
+ body: expect.arrayContaining([
+ expect.objectContaining({
+ component: 'Button',
+ context: expect.objectContaining({
+ href: `https://island.is/minarsidur/login?login_hint=${delegationSubjectId}&target_link_uri=https://island.is/minarsidur/postholf`,
+ }),
+ }),
+ ]),
+ }),
+ }),
+ )
+ })
+
+ it('should use clickActionUrl that is provided if the url is not a service portal url', async () => {
+ const notServicePortalUrl = 'https://island.is/something-else/'
+ jest
+ .spyOn(notificationsService, 'getTemplate')
+ .mockReturnValue(
+ Promise.resolve(
+ getMockHnippTemplate({ clickActionUrl: notServicePortalUrl }),
+ ),
+ )
+
+ await addToQueue(userWithDelegations2.nationalId)
+
+ // should send email to primary recipient
+ expect(emailService.sendEmail).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ to: expect.objectContaining({
+ name: userWithDelegations2.name,
address: userWithDelegations2.email,
}),
+ // should not use 3rd party login because the clickActionUrl is not a service portal url
+ template: expect.objectContaining({
+ body: expect.arrayContaining([
+ expect.objectContaining({
+ component: 'Button',
+ context: expect.objectContaining({
+ href: notServicePortalUrl,
+ }),
+ }),
+ ]),
+ }),
}),
)
@@ -182,28 +316,40 @@ describe('NotificationsWorkerService', () => {
2,
expect.objectContaining({
to: expect.objectContaining({
- name: mockFullName,
+ name: userWithDelegations2.name, // should use the original recipient name
address: userWithDelegations.email,
}),
+ // should not use 3rd party login because the clickActionUrl is not a service portal url
+ template: expect.objectContaining({
+ body: expect.arrayContaining([
+ expect.objectContaining({
+ component: 'Button',
+ context: expect.objectContaining({
+ href: notServicePortalUrl,
+ }),
+ }),
+ ]),
+ }),
}),
)
})
it('should not send email or push notification if we are outside working hours (8 AM - 11 PM) ', async () => {
- const outsideWorkingHours = new Date(2021, 1, 1, 7, 59, 58) // 2 seconds before 8 AM
+ // set time to be outside of working hours
jest.setSystemTime(outsideWorkingHours)
- await addToQueue(userWitNoDelegations.nationalId)
+ await addToQueue(userWithNoDelegations.nationalId)
expect(emailService.sendEmail).not.toHaveBeenCalled()
expect(notificationDispatch.sendPushNotification).not.toHaveBeenCalled()
- await wait(2) // ensure we are at 8 AM by waiting 2 seconds
+ // reset time to inside working hour
+ jest.advanceTimersByTime(workingHoursDelta)
+ // give worker some time to process message
+ await wait(2)
expect(emailService.sendEmail).toHaveBeenCalledTimes(1)
expect(notificationDispatch.sendPushNotification).toHaveBeenCalledTimes(1)
-
- jest.setSystemTime(insideWorkingHours) // reset time
}, 10_000)
it('should not send email or push notification if no profile is found for recipient', async () => {
diff --git a/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/notificationsWorker.service.ts b/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/notificationsWorker.service.ts
index 2e383f47af65..de003601138c 100644
--- a/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/notificationsWorker.service.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/notificationsWorker/notificationsWorker.service.ts
@@ -1,12 +1,17 @@
import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common'
import { join } from 'path'
import { InjectModel } from '@nestjs/sequelize'
+import { isCompany } from 'kennitala'
import { User } from '@island.is/auth-nest-tools'
import { NationalRegistryV3ClientService } from '@island.is/clients/national-registry-v3'
-import { UserProfileDto, V2UsersApi } from '@island.is/clients/user-profile'
+import {
+ UserProfileDto,
+ V2UsersApi,
+ ActorProfileDto,
+} from '@island.is/clients/user-profile'
import { DelegationsApi } from '@island.is/clients/auth/delegation-api'
-import { EmailService, Message, Body } from '@island.is/email-service'
+import { Body, EmailService, Message } from '@island.is/email-service'
import type { Logger } from '@island.is/logging'
import { LOGGER_PROVIDER } from '@island.is/logging'
import {
@@ -24,13 +29,23 @@ import { CreateHnippNotificationDto } from '../dto/createHnippNotification.dto'
import { NotificationsService } from '../notifications.service'
import { HnippTemplate } from '../dto/hnippTemplate.response'
import { Notification } from '../notification.model'
-
+import type { Locale } from '@island.is/shared/types'
export const IS_RUNNING_AS_WORKER = Symbol('IS_NOTIFICATION_WORKER')
+export const SERVICE_PORTAL_CLICK_ACTION_URL = Symbol(
+ 'SERVICE_PORTAL_CLICK_ACTION_URL',
+)
+
const WORK_STARTING_HOUR = 8 // 8 AM
const WORK_ENDING_HOUR = 23 // 11 PM
type HandleNotification = {
- profile: UserProfileDto
+ profile: {
+ nationalId: string
+ email?: string
+ documentNotifications: boolean
+ emailNotifications: boolean
+ locale?: string
+ }
messageId: string
message: CreateHnippNotificationDto
}
@@ -51,6 +66,8 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
private readonly logger: Logger,
@Inject(IS_RUNNING_AS_WORKER)
private readonly isRunningAsWorker: boolean,
+ @Inject(SERVICE_PORTAL_CLICK_ACTION_URL)
+ private readonly servicePortalClickActionUrl: string,
@Inject(EmailService)
private readonly emailService: EmailService,
@InjectModel(Notification)
@@ -69,7 +86,15 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
messageId,
message,
}: HandleNotification) {
- // don't send message unless user wants this type of notification
+ // don't send message unless user wants this type of notification and national id is a person.
+ if (isCompany(profile.nationalId)) {
+ this.logger.info(
+ 'User is not a person and will not receive document notifications',
+ { messageId },
+ )
+
+ return
+ }
if (!profile.documentNotifications) {
this.logger.info(
'User does not have notifications enabled this message type',
@@ -85,7 +110,7 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
const notification = await this.messageProcessor.convertToNotification(
message,
- profile,
+ profile.locale as Locale,
)
await this.notificationDispatch.sendPushNotification({
@@ -98,15 +123,15 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
createEmail({
isEnglish,
recipientEmail,
- template,
formattedTemplate,
fullName,
+ subjectId,
}: {
isEnglish: boolean
recipientEmail: string | null
- template: HnippTemplate
formattedTemplate: HnippTemplate
fullName: string
+ subjectId?: string
}): Message {
if (!recipientEmail) {
throw new Error('User does not have email notifications enabled')
@@ -148,7 +173,7 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
component: 'Button',
context: {
copy: `${isEnglish ? 'View on' : 'Skoða á'} island.is`,
- href: formattedTemplate.clickActionUrl,
+ href: this.getClickActionUrl(formattedTemplate, subjectId),
},
},
{
@@ -174,15 +199,15 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
return {
from: {
name: 'Ísland.is',
- address: 'no-reply@island.is',
+ address: 'noreply@island.is',
},
to: {
name: fullName,
address: recipientEmail,
},
- subject: template.notificationTitle,
+ subject: formattedTemplate.notificationTitle,
template: {
- title: template.notificationTitle,
+ title: formattedTemplate.notificationTitle,
body: generateBody(),
},
}
@@ -216,12 +241,22 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
return
}
- const [template, individual] = await Promise.all([
- this.notificationsService.getTemplate(message.templateId, profile.locale),
- this.nationalRegistryService.getName(profile.nationalId),
- ])
+ const template = await this.notificationsService.getTemplate(
+ message.templateId,
+ profile.locale as Locale,
+ )
+
+ let fullName = message.onBehalfOf?.name ?? ''
+
+ // if we don't have a full name, we try to get it from the national registry
+ if (!fullName) {
+ // we always use the name of the original recipient in the email
+ const nationalIdOfOriginalRecipient =
+ message.onBehalfOf?.nationalId ?? profile.nationalId
+
+ fullName = await this.getFullName(nationalIdOfOriginalRecipient)
+ }
- const fullName = individual?.fulltNafn ?? ''
const isEnglish = profile.locale === 'en'
const formattedTemplate = this.notificationsService.formatArguments(
@@ -235,11 +270,11 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
try {
const emailContent = this.createEmail({
+ formattedTemplate,
isEnglish,
recipientEmail: profile.email ?? null,
- template,
- formattedTemplate,
fullName,
+ subjectId: message.onBehalfOf?.subjectId,
})
await this.emailService.sendEmail(emailContent)
@@ -315,10 +350,21 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
}
}
- const profile =
- await this.userProfileApi.userProfileControllerFindUserProfile({
- xParamNationalId: message.recipient,
- })
+ // get actor profile if sending to delegation holder, else get user profile
+ let profile: UserProfileDto | ActorProfileDto
+
+ if (message.onBehalfOf) {
+ profile =
+ await this.userProfileApi.userProfileControllerGetActorProfile({
+ xParamToNationalId: message.recipient,
+ xParamFromNationalId: message.onBehalfOf.nationalId,
+ })
+ } else {
+ profile =
+ await this.userProfileApi.userProfileControllerFindUserProfile({
+ xParamNationalId: message.recipient,
+ })
+ }
// can't send message if user has no user profile
if (!profile) {
@@ -329,8 +375,8 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
this.logger.info('User found for message', { messageId })
- const handleNotificationArgs = {
- profile,
+ const handleNotificationArgs: HandleNotification = {
+ profile: { ...profile, nationalId: message.recipient },
messageId,
message,
}
@@ -360,17 +406,27 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
const delegations =
await this.delegationsApi.delegationsControllerGetDelegationRecords(
{
- xQueryFromNationalId: message.recipient,
+ xQueryNationalId: message.recipient,
scope: DocumentsScope.main,
},
)
+ let recipientName = ''
+
+ if (delegations.data.length > 0) {
+ recipientName = await this.getFullName(message.recipient)
+ }
+
await Promise.all(
delegations.data.map((delegation) =>
this.queue.add({
...message,
recipient: delegation.toNationalId,
- onBehalfOf: message.recipient,
+ onBehalfOf: {
+ nationalId: message.recipient,
+ name: recipientName,
+ subjectId: delegation.subjectId,
+ },
}),
),
)
@@ -386,4 +442,37 @@ export class NotificationsWorkerService implements OnApplicationBootstrap {
},
)
}
+
+ private async getFullName(nationalId: string): Promise {
+ const individual = await this.nationalRegistryService.getName(nationalId)
+ return individual?.fulltNafn ?? ''
+ }
+
+ /* Private methods */
+
+ // When sending email to delegation holder we want to use third party login if we have a subjectId and are sending to a service portal url
+ private getClickActionUrl(
+ formattedTemplate: HnippTemplate,
+ subjectId?: string,
+ ) {
+ if (!formattedTemplate.clickActionUrl) {
+ return ''
+ }
+
+ if (!subjectId) {
+ return formattedTemplate.clickActionUrl
+ }
+
+ const shouldUseThirdPartyLogin = formattedTemplate.clickActionUrl.includes(
+ this.servicePortalClickActionUrl,
+ )
+
+ return shouldUseThirdPartyLogin
+ ? `${
+ this.servicePortalClickActionUrl
+ }/login?login_hint=${subjectId}&target_link_uri=${encodeURI(
+ formattedTemplate.clickActionUrl,
+ )}`
+ : formattedTemplate.clickActionUrl
+ }
}
diff --git a/apps/services/user-notification/src/app/modules/notifications/tests/me-notifications.controller.spec.ts b/apps/services/user-notification/src/app/modules/notifications/tests/me-notifications.controller.spec.ts
new file mode 100644
index 000000000000..5daf7c7fd46a
--- /dev/null
+++ b/apps/services/user-notification/src/app/modules/notifications/tests/me-notifications.controller.spec.ts
@@ -0,0 +1,109 @@
+import request from 'supertest'
+import { AppModule } from '../../../app.module'
+import {
+ getRequestMethod,
+ setupApp,
+ setupAppWithoutAuth,
+ TestEndpointOptions,
+} from '@island.is/testing/nest'
+import { SequelizeConfigService } from '../../../sequelizeConfig.service'
+import { NotificationsScope } from '@island.is/auth/scopes'
+import { createCurrentUser } from '@island.is/testing/fixtures'
+
+beforeAll(async () => {
+ process.env.INIT_SCHEMA = 'true' // Disabling Firebase init
+})
+
+describe('MeNotificationsController - No Auth', () => {
+ it.each`
+ method | endpoint
+ ${'GET'} | ${'/v1/me/notifications'}
+ ${'GET'} | ${'/v1/me/notifications/some-notification-id'}
+ ${'GET'} | ${'/v1/me/notifications/unread-count'}
+ ${'GET'} | ${'/v1/me/notifications/unseen-count'}
+ ${'PATCH'} | ${'/v1/me/notifications/some-notification-id'}
+ ${'PATCH'} | ${'/v1/me/notifications/mark-all-as-seen'}
+ `(
+ '$method $endpoint should return 401 when user is unauthenticated',
+ async ({ method, endpoint }: TestEndpointOptions) => {
+ //Arrange
+ const app = await setupAppWithoutAuth({
+ AppModule: AppModule,
+ SequelizeConfigService: SequelizeConfigService,
+ })
+ const server = request(app.getHttpServer())
+
+ //Act
+ const res = await getRequestMethod(server, method)(endpoint)
+
+ //Assert
+ expect(res.status).toEqual(401)
+
+ app.cleanUp()
+ },
+ )
+})
+
+describe('MeNotificationsController - With Auth No Scope', () => {
+ it.each`
+ method | endpoint
+ ${'GET'} | ${'/v1/me/notifications'}
+ ${'GET'} | ${'/v1/me/notifications/some-notification-id'}
+ ${'GET'} | ${'/v1/me/notifications/unread-count'}
+ ${'GET'} | ${'/v1/me/notifications/unseen-count'}
+ ${'PATCH'} | ${'/v1/me/notifications/some-notification-id'}
+ ${'PATCH'} | ${'/v1/me/notifications/mark-all-as-seen'}
+ `(
+ '$method $endpoint should return 403 when user is unauthorized',
+ async ({ method, endpoint }: TestEndpointOptions) => {
+ //Arrange
+ const app = await setupApp({
+ AppModule,
+ SequelizeConfigService,
+ user: createCurrentUser({}),
+ })
+ const server = request(app.getHttpServer())
+
+ //Act
+ const res = await getRequestMethod(server, method)(endpoint)
+
+ //Assert
+ expect(res.status).toEqual(403)
+
+ app.cleanUp()
+ },
+ )
+})
+
+// describe('MeNotificationsController - With Auth And Scope', () => {
+// it.each`
+// method | endpoint
+// ${'GET'} | ${'/v1/me/notifications'}
+// ${'GET'} | ${'/v1/me/notifications/some-notification-id'}
+// ${'GET'} | ${'/v1/me/notifications/unread-count'}
+// ${'GET'} | ${'/v1/me/notifications/unseen-count'}
+// ${'PATCH'} | ${'/v1/me/notifications/some-notification-id'}
+// ${'PATCH'} | ${'/v1/me/notifications/mark-all-as-seen'}
+// `(
+// '$method $endpoint should return 200 when user is authorized',
+// async ({ method, endpoint }: TestEndpointOptions) => {
+// //Arrange
+// const app = await setupApp({
+// AppModule,
+// SequelizeConfigService,
+// user: createCurrentUser({
+// scope: [NotificationsScope.read, NotificationsScope.write],
+// }),
+// })
+// const server = request(app.getHttpServer())
+
+// //Act
+// const res = await getRequestMethod(server, method)(endpoint)
+
+// //Assert
+// expect([200, 204].includes(res.status)).toBe(true)
+
+// app.cleanUp()
+// },
+// )
+// })
diff --git a/apps/services/user-notification/src/app/modules/notifications/messageProcessor.service.spec.ts b/apps/services/user-notification/src/app/modules/notifications/tests/messageProcessor.service.spec.ts
similarity index 78%
rename from apps/services/user-notification/src/app/modules/notifications/messageProcessor.service.spec.ts
rename to apps/services/user-notification/src/app/modules/notifications/tests/messageProcessor.service.spec.ts
index aace86bdca23..4c4c596055dc 100644
--- a/apps/services/user-notification/src/app/modules/notifications/messageProcessor.service.spec.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/tests/messageProcessor.service.spec.ts
@@ -1,17 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing'
-import { MessageProcessorService } from './messageProcessor.service'
+import { MessageProcessorService } from '../messageProcessor.service'
import { LoggingModule } from '@island.is/logging'
import { logger, LOGGER_PROVIDER } from '@island.is/logging'
-import { HnippTemplate } from './dto/hnippTemplate.response'
-import { CreateHnippNotificationDto } from './dto/createHnippNotification.dto'
+import { HnippTemplate } from '../dto/hnippTemplate.response'
+import { CreateHnippNotificationDto } from '../dto/createHnippNotification.dto'
import { CacheModule } from '@nestjs/cache-manager'
-import { NotificationsService } from './notifications.service'
-import {
- UserProfile,
- UserProfileLocaleEnum,
-} from '@island.is/clients/user-profile'
+import { NotificationsService } from '../notifications.service'
import { getModelToken } from '@nestjs/sequelize'
-import { Notification } from './notification.model'
+import { Notification } from '../notification.model'
const mockHnippTemplate: HnippTemplate = {
templateId: 'HNIPP.DEMO.ID',
@@ -33,24 +29,7 @@ const mockCreateHnippNotificationDto: CreateHnippNotificationDto = {
],
}
-const mockProfile: UserProfile = {
- nationalId: '1234567890',
- mobilePhoneNumber: '1234567',
- email: 'foo@bar.com',
- locale: UserProfileLocaleEnum.Is,
- documentNotifications: true,
- created: new Date(),
- modified: new Date(),
- id: '1234567',
- emailVerified: true,
- mobilePhoneNumberVerified: true,
- profileImageUrl: '',
- emailStatus: 'VERIFIED',
- mobileStatus: 'VERIFIED',
- lastNudge: new Date(),
- emailNotifications: true,
- nextNudge: new Date(),
-}
+const mockLocale = 'is'
describe('MessageProcessorService', () => {
let service: MessageProcessorService
@@ -93,7 +72,7 @@ describe('MessageProcessorService', () => {
const notification = await service.convertToNotification(
mockCreateHnippNotificationDto,
- mockProfile,
+ mockLocale,
)
expect(notification.title).toMatch('Demo title')
expect(notification.body).toMatch('Demo body hello')
@@ -112,7 +91,7 @@ describe('MessageProcessorService', () => {
const notification1 = await service.convertToNotification(
mockCreateHnippNotificationDto,
- mockProfile,
+ mockLocale,
)
const notification2 = await service.convertToNotification(
{
@@ -122,7 +101,7 @@ describe('MessageProcessorService', () => {
{ key: 'arg2', value: 'world2' },
],
},
- mockProfile,
+ mockLocale,
)
expect(notification1.title).toMatch('Demo title')
diff --git a/apps/services/user-notification/src/app/modules/notifications/tests/notifications.controller.spec.ts b/apps/services/user-notification/src/app/modules/notifications/tests/notifications.controller.spec.ts
new file mode 100644
index 000000000000..2b3b8bfe5f3c
--- /dev/null
+++ b/apps/services/user-notification/src/app/modules/notifications/tests/notifications.controller.spec.ts
@@ -0,0 +1,142 @@
+import { Test, TestingModule } from '@nestjs/testing'
+import { NotificationsController } from '../notifications.controller'
+import { NotificationsService } from '../notifications.service'
+import { QueueService } from '@island.is/message-queue'
+import { CreateHnippNotificationDto } from '../dto/createHnippNotification.dto'
+import { HnippTemplate } from '../dto/hnippTemplate.response'
+import { LOGGER_PROVIDER } from '@island.is/logging'
+import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager'
+
+describe('NotificationsController', () => {
+ let controller: NotificationsController
+ let notificationsService: NotificationsService
+ let queueService: QueueService
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ imports: [CacheModule.register()],
+ controllers: [NotificationsController],
+ providers: [
+ {
+ provide: NotificationsService,
+ useValue: {
+ validate: jest.fn(),
+ getTemplates: jest.fn(),
+ getTemplate: jest.fn(),
+ },
+ },
+ {
+ provide: LOGGER_PROVIDER,
+ useValue: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+ },
+ {
+ provide: 'IslandIsMessageQueue/QueueService/notifications',
+ useValue: { add: jest.fn().mockResolvedValue('mockQueueId') },
+ },
+ {
+ provide: CacheInterceptor,
+ useValue: jest.fn(),
+ },
+ ],
+ }).compile()
+
+ controller = module.get(NotificationsController)
+ notificationsService =
+ module.get(NotificationsService)
+ queueService = module.get('IslandIsMessageQueue/QueueService/notifications')
+ })
+
+ // Individual tests go here
+ describe('getNotificationTemplates', () => {
+ it('should return an array of notification templates', async () => {
+ const templates: HnippTemplate[] = [
+ {
+ templateId: 'HNIPP.POSTHOLF.NEW_DOCUMENT',
+ notificationTitle: 'New document',
+ notificationBody: 'New document from {{organization}}',
+ args: ['arg1', 'arg2'],
+ },
+ ]
+
+ jest
+ .spyOn(notificationsService, 'getTemplates')
+ .mockResolvedValue(templates)
+
+ const locale = 'is'
+ const result = await controller.getNotificationTemplates(locale)
+
+ expect(notificationsService.getTemplates).toHaveBeenCalledWith(locale)
+ expect(result).toEqual(templates)
+ })
+ })
+
+ describe('getNotificationTemplate', () => {
+ it('should return a single notification template by ID', async () => {
+ const template: HnippTemplate = {
+ templateId: 'HNIPP.POSTHOLF.NEW_DOCUMENT',
+ notificationTitle: 'Title',
+ notificationBody: 'Body',
+ args: ['arg1'],
+ }
+
+ jest
+ .spyOn(notificationsService, 'getTemplate')
+ .mockResolvedValue(template)
+
+ const result = await controller.getNotificationTemplate(
+ 'HNIPP.POSTHOLF.NEW_DOCUMENT',
+ 'is',
+ )
+
+ expect(notificationsService.getTemplate).toHaveBeenCalledWith(
+ 'HNIPP.POSTHOLF.NEW_DOCUMENT',
+ 'is',
+ )
+ expect(result).toEqual(template)
+ })
+ })
+ describe('createHnippNotification', () => {
+ it('should validate and queue a new Hnipp notification, returning a response with the id', async () => {
+ const createHnippNotificationDto: CreateHnippNotificationDto = {
+ recipient: '1234567890',
+ templateId: 'HNIPP.POSTHOLF.NEW_DOCUMENT',
+ args: [
+ { key: 'organization', value: 'Test Organization' },
+ { key: 'documentId', value: '1234' },
+ ],
+ }
+
+ jest.spyOn(notificationsService, 'validate').mockResolvedValue(undefined)
+
+ const mockQueueId = 'mockQueueId'
+ jest.spyOn(queueService, 'add').mockResolvedValue(mockQueueId)
+
+ let validationError
+ try {
+ await controller.createHnippNotification(createHnippNotificationDto)
+ } catch (error) {
+ validationError = error
+ }
+
+ // Asserting the validation passed by checking no error was thrown
+ expect(validationError).toBeUndefined()
+
+ // Additionally, checking the validate method was called correctly
+ expect(notificationsService.validate).toHaveBeenCalledWith(
+ createHnippNotificationDto.templateId,
+ createHnippNotificationDto.args,
+ )
+
+ // Assert that the queueService.add was called with any argument, considering
+ // the test setup does not specify the exact argument structure.
+ expect(queueService.add).toHaveBeenCalledWith(expect.anything())
+
+ // If reaching this point without errors, validation is considered successful.
+ expect(true).toBe(true) // This is implicitly true if no error was thrown.
+ })
+ })
+})
diff --git a/apps/services/user-notification/src/app/modules/notifications/notifications.service.spec.ts b/apps/services/user-notification/src/app/modules/notifications/tests/notifications.service.spec.ts
similarity index 75%
rename from apps/services/user-notification/src/app/modules/notifications/notifications.service.spec.ts
rename to apps/services/user-notification/src/app/modules/notifications/tests/notifications.service.spec.ts
index 71f1d1ff511f..b6fad4b1829e 100644
--- a/apps/services/user-notification/src/app/modules/notifications/notifications.service.spec.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/tests/notifications.service.spec.ts
@@ -1,11 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing'
-import { NotificationsService } from './notifications.service'
+import { NotificationsService } from '../notifications.service'
import { LoggingModule, logger, LOGGER_PROVIDER } from '@island.is/logging'
-import { HnippTemplate } from './dto/hnippTemplate.response'
-import { CreateHnippNotificationDto } from './dto/createHnippNotification.dto'
+import { HnippTemplate } from '../dto/hnippTemplate.response'
+import { CreateHnippNotificationDto } from '../dto/createHnippNotification.dto'
import { CacheModule } from '@nestjs/cache-manager'
import { getModelToken } from '@nestjs/sequelize'
-import { Notification } from './notification.model'
+import { Notification } from '../notification.model'
import { NotificationsScope } from '@island.is/auth/scopes'
import type { User } from '@island.is/auth-nest-tools'
@@ -14,7 +14,9 @@ import {
UpdateNotificationDto,
RenderedNotificationDto,
PaginatedNotificationDto,
-} from './dto/notification.dto'
+ UnreadNotificationsCountDto,
+ UnseenNotificationsCountDto,
+} from '../dto/notification.dto'
const user: User = {
nationalId: '1234567890',
@@ -82,8 +84,8 @@ describe('NotificationsService', () => {
it('should get template', async () => {
jest
- .spyOn(service, 'getTemplates')
- .mockImplementation(() => Promise.resolve(mockTemplates))
+ .spyOn(service, 'getTemplate')
+ .mockImplementation(() => Promise.resolve(mockHnippTemplate))
const template = await service.getTemplate(mockHnippTemplate.templateId)
expect(template).toBeInstanceOf(Object)
})
@@ -143,13 +145,6 @@ describe('NotificationsService', () => {
expect(template.clickAction).toEqual('Demo click action world')
})
- it('should return the correct locale mapping', async () => {
- expect(service.mapLocale('en')).toBe('en')
- expect(service.mapLocale('is')).toBe('is-IS')
- expect(service.mapLocale(null)).toBe('is-IS')
- expect(service.mapLocale(undefined)).toBe('is-IS')
- })
-
describe('findMany', () => {
it('should return a paginated list of notifications', async () => {
const query = new ExtendedPaginationDto()
@@ -165,13 +160,12 @@ describe('NotificationsService', () => {
describe('findOne', () => {
it('should return a specific notification', async () => {
const id = 123
- const locale = 'en'
const mockedResponse = new RenderedNotificationDto()
jest
.spyOn(service, 'findOne')
.mockImplementation(async () => mockedResponse)
- expect(await service.findOne(user, id, locale)).toBe(mockedResponse)
+ expect(await service.findOne(user, id, 'en')).toBe(mockedResponse)
})
})
@@ -179,15 +173,48 @@ describe('NotificationsService', () => {
it('should update a notification', async () => {
const id = 123
const updateNotificationDto = new UpdateNotificationDto()
- const locale = 'en' // Mock locale
const mockedResponse = new RenderedNotificationDto()
jest
.spyOn(service, 'update')
.mockImplementation(async () => mockedResponse)
- expect(
- await service.update(user, id, updateNotificationDto, locale),
- ).toBe(mockedResponse)
+ expect(await service.update(user, id, updateNotificationDto, 'en')).toBe(
+ mockedResponse,
+ )
+ })
+ })
+
+ describe('Seen', () => {
+ it('should get all unseen notification count', async () => {
+ const mockedResponse = new UnseenNotificationsCountDto()
+ jest
+ .spyOn(service, 'getUnseenNotificationsCount')
+ .mockImplementation(async () => mockedResponse)
+
+ expect(await service.getUnseenNotificationsCount(user)).toBe(
+ mockedResponse,
+ )
+ })
+
+ it('should mark all notifications as seen', async () => {
+ jest
+ .spyOn(service, 'markAllAsSeen')
+ .mockImplementation(async () => undefined)
+
+ expect(await service.markAllAsSeen(user)).toBe(undefined)
+ })
+ })
+
+ describe('unread', () => {
+ it('should get all unread notification count', async () => {
+ const mockedResponse = new UnreadNotificationsCountDto()
+ jest
+ .spyOn(service, 'getUnreadNotificationsCount')
+ .mockImplementation(async () => mockedResponse)
+
+ expect(await service.getUnreadNotificationsCount(user)).toBe(
+ mockedResponse,
+ )
})
})
})
diff --git a/apps/services/user-notification/src/app/modules/notifications/utils.ts b/apps/services/user-notification/src/app/modules/notifications/utils.ts
index 256bafec4c1e..6c9800b10ea3 100644
--- a/apps/services/user-notification/src/app/modules/notifications/utils.ts
+++ b/apps/services/user-notification/src/app/modules/notifications/utils.ts
@@ -1 +1,7 @@
+import type { Locale } from '@island.is/shared/types'
export const isDefined = (x: T | null | undefined): x is T => x != null
+export const mapToLocale = (locale: string): Locale =>
+ locale === 'en' ? 'en' : 'is'
+export const mapToContentfulLocale = (locale: Locale): string => {
+ return locale === 'en' ? 'en' : 'is-IS'
+}
diff --git a/apps/services/user-notification/src/buildOpenApi.ts b/apps/services/user-notification/src/buildOpenApi.ts
index 4ed8397bbe42..96ddb17475dd 100644
--- a/apps/services/user-notification/src/buildOpenApi.ts
+++ b/apps/services/user-notification/src/buildOpenApi.ts
@@ -6,4 +6,5 @@ buildOpenApi({
path: 'apps/services/user-notification/src/openapi.yaml',
appModule: AppModule,
openApi,
+ enableVersioning: true,
})
diff --git a/apps/services/user-notification/src/config.ts b/apps/services/user-notification/src/config.ts
new file mode 100644
index 000000000000..571e6fdb790e
--- /dev/null
+++ b/apps/services/user-notification/src/config.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod'
+
+import { defineConfig } from '@island.is/nest/config'
+import { processJob } from '@island.is/infra-nest-server'
+
+const schema = z.object({
+ appProtocol: z.string(),
+ isWorker: z.boolean(),
+ firebaseCredentials: z.string(),
+ servicePortalClickActionUrl: z.string(),
+ contentfulAccessToken: z.string(),
+})
+
+export const UserNotificationsConfig = defineConfig({
+ name: 'UserNotificationsApi',
+ schema,
+ load(env) {
+ return {
+ appProtocol: env.required(
+ 'USER_NOTIFICATION_APP_PROTOCOL',
+ 'is.island.app.dev',
+ ),
+ isWorker: processJob() === 'worker',
+ firebaseCredentials: env.required('FIREBASE_CREDENTIALS', ''),
+ servicePortalClickActionUrl:
+ env.optional('SERVICE_PORTAL_CLICK_ACTION_URL') ??
+ 'https://island.is/minarsidur',
+ contentfulAccessToken: env.optional('CONTENTFUL_ACCESS_TOKEN', ''),
+ }
+ },
+})
diff --git a/apps/services/user-notification/src/environments/environment.ts b/apps/services/user-notification/src/environments/environment.ts
index 3754ac083c73..1463c0e09fe1 100644
--- a/apps/services/user-notification/src/environments/environment.ts
+++ b/apps/services/user-notification/src/environments/environment.ts
@@ -1,5 +1,3 @@
-import { processJob } from '@island.is/infra-nest-server'
-
let env = process.env
const isDevelopment = env.NODE_ENV === 'development'
@@ -19,17 +17,14 @@ if (!env.NODE_ENV || isDevelopment) {
const required = (name: string): string => env[name] ?? ''
-const job = processJob()
-
export const environment = {
- appProtocol: required('USER_NOTIFICATION_APP_PROTOCOL'),
-
- isWorker: job === 'worker', // refactor this
-
- firebaseCredentials: required('FIREBASE_CREDENTIALS'),
-
mainQueueName: required('MAIN_QUEUE_NAME'),
deadLetterQueueName: env.DEAD_LETTER_QUEUE_NAME,
+ auth: {
+ issuer:
+ env.IDENTITY_SERVER_ISSUER_URL ??
+ 'https://identity-server.dev01.devland.is',
+ },
sqsConfig: {
endpoint: env.SQS_ENDPOINT,
@@ -42,12 +37,6 @@ export const environment = {
},
}),
},
- contentfulAccessToken: env.CONTENTFUL_ACCESS_TOKEN,
- auth: {
- issuer:
- env.IDENTITY_SERVER_ISSUER_URL ??
- 'https://identity-server.dev01.devland.is',
- },
emailOptions: isDevelopment
? {
diff --git a/apps/services/user-notification/test/environment.ts b/apps/services/user-notification/test/environment.ts
index 7984039c0e56..2bc8289d1ea0 100644
--- a/apps/services/user-notification/test/environment.ts
+++ b/apps/services/user-notification/test/environment.ts
@@ -3,3 +3,4 @@ process.env.DEAD_LETTER_QUEUE_NAME = 'test-failure'
process.env.SQS_REGION = 'eu-west-1'
process.env.SQS_ACCESS_KEY = 'testing'
process.env.SQS_SECRET_ACCESS_KEY = 'testing'
+process.env.SERVICE_PORTAL_CLICK_ACTION_URL = 'https://island.is/minarsidur'
diff --git a/apps/services/user-profile/infra/service-portal-api.ts b/apps/services/user-profile/infra/service-portal-api.ts
index 65e984f59ba2..414784d80f59 100644
--- a/apps/services/user-profile/infra/service-portal-api.ts
+++ b/apps/services/user-profile/infra/service-portal-api.ts
@@ -1,8 +1,13 @@
-import { service, ServiceBuilder } from '../../../../infra/src/dsl/dsl'
+import { json, service, ServiceBuilder } from '../../../../infra/src/dsl/dsl'
import {
EnvironmentVariables,
Secrets,
} from '../../../../infra/src/dsl/types/input-types'
+import {
+ Base,
+ Client,
+ NationalRegistryB2C,
+} from '../../../../infra/src/dsl/xroad'
// We basically don't want it to run in a cron job
// but manually, so set it to run once a year on dec 31st
@@ -36,6 +41,15 @@ const envVariables: EnvironmentVariables = {
staging: '3000',
prod: '3000',
},
+ AUTH_DELEGATION_API_URL: {
+ dev: 'http://web-services-auth-delegation-api.identity-server-delegation.svc.cluster.local',
+ staging:
+ 'http://web-services-auth-delegation-api.identity-server-delegation.svc.cluster.local',
+ prod: 'https://auth-delegation-api.internal.innskra.island.is',
+ },
+ AUTH_DELEGATION_MACHINE_CLIENT_SCOPE: json([
+ '@island.is/auth/delegations/index:system',
+ ]),
}
const secrets: Secrets = {
@@ -48,6 +62,10 @@ const secrets: Secrets = {
EMAIL_REPLY_TO_NAME: '/k8s/service-portal/api/EMAIL_REPLY_TO_NAME',
ISLYKILL_SERVICE_PASSPHRASE: '/k8s/api/ISLYKILL_SERVICE_PASSPHRASE',
ISLYKILL_SERVICE_BASEPATH: '/k8s/api/ISLYKILL_SERVICE_BASEPATH',
+ IDENTITY_SERVER_CLIENT_ID: `/k8s/service-portal/api/SERVICE_PORTAL_API_CLIENT_ID`,
+ IDENTITY_SERVER_CLIENT_SECRET: `/k8s/service-portal/api/SERVICE_PORTAL_API_CLIENT_SECRET`,
+ NATIONAL_REGISTRY_B2C_CLIENT_SECRET:
+ '/k8s/api/NATIONAL_REGISTRY_B2C_CLIENT_SECRET',
}
export const workerSetup = (): ServiceBuilder =>
@@ -83,6 +101,7 @@ export const serviceSetup = (): ServiceBuilder =>
.serviceAccount(serviceId)
.env(envVariables)
.secrets(secrets)
+ .xroad(Base, Client, NationalRegistryB2C)
.migrations()
.liveness('/liveness')
.readiness('/readiness')
diff --git a/apps/services/user-profile/migrations/20240408162626-create-actor-profile.js b/apps/services/user-profile/migrations/20240408162626-create-actor-profile.js
new file mode 100644
index 000000000000..ef96bd481ebe
--- /dev/null
+++ b/apps/services/user-profile/migrations/20240408162626-create-actor-profile.js
@@ -0,0 +1,50 @@
+'use strict'
+
+module.exports = {
+ up: (queryInterface, Sequelize) => {
+ return queryInterface
+ .createTable('actor_profile', {
+ id: {
+ type: Sequelize.UUID,
+ primaryKey: true,
+ allowNull: false,
+ defaultValue: Sequelize.UUIDV4,
+ },
+ to_national_id: {
+ type: Sequelize.STRING,
+ allowNull: false,
+ },
+ from_national_id: {
+ type: Sequelize.STRING,
+ allowNull: false,
+ },
+ email_notifications: {
+ type: Sequelize.BOOLEAN,
+ defaultValue: true,
+ allowNull: false,
+ },
+ created: {
+ type: 'TIMESTAMP WITH TIME ZONE',
+ defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
+ allowNull: false,
+ },
+ modified: {
+ type: 'TIMESTAMP WITH TIME ZONE',
+ defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
+ allowNull: false,
+ },
+ })
+ .then(() =>
+ queryInterface.addConstraint('actor_profile', {
+ fields: ['to_national_id', 'from_national_id'],
+ type: 'unique',
+ name: 'actor_profile_to_from_unique',
+ }),
+ )
+ .then(() => queryInterface.addIndex('actor_profile', ['to_national_id']))
+ },
+
+ down: (queryInterface) => {
+ return queryInterface.dropTable('actor_profile')
+ },
+}
diff --git a/apps/services/user-profile/src/app/app.module.ts b/apps/services/user-profile/src/app/app.module.ts
index 3e5ad29da837..99bb6a931cd5 100644
--- a/apps/services/user-profile/src/app/app.module.ts
+++ b/apps/services/user-profile/src/app/app.module.ts
@@ -5,14 +5,35 @@ import { AuthModule } from '@island.is/auth-nest-tools'
import { LoggingModule } from '@island.is/logging'
import { AuditModule } from '@island.is/nest/audit'
import { ProblemModule } from '@island.is/nest/problem'
+import {
+ ConfigModule,
+ IdsClientConfig,
+ XRoadConfig,
+} from '@island.is/nest/config'
import environment from '../environments/environment'
import { SequelizeConfigService } from './sequelizeConfig.service'
import { UserProfileModule } from './user-profile/userProfile.module'
import { UserProfileModule as UserProfileV2Module } from './v2/user-profile.module'
+import { AuthDelegationApiClientConfig } from '@island.is/clients/auth/delegation-api'
+import { NationalRegistryV3ClientConfig } from '@island.is/clients/national-registry-v3'
+import { FeatureFlagConfig } from '@island.is/nest/feature-flags'
+import { UserProfileConfig } from '../config'
+
@Module({
imports: [
+ ConfigModule.forRoot({
+ isGlobal: true,
+ load: [
+ XRoadConfig,
+ NationalRegistryV3ClientConfig,
+ FeatureFlagConfig,
+ IdsClientConfig,
+ AuthDelegationApiClientConfig,
+ UserProfileConfig,
+ ],
+ }),
AuditModule.forRoot(environment.audit),
AuthModule.register(environment.auth),
SequelizeModule.forRootAsync({
diff --git a/apps/services/user-profile/src/app/types/ClientType.ts b/apps/services/user-profile/src/app/types/ClientType.ts
new file mode 100644
index 000000000000..9d10967dd3f9
--- /dev/null
+++ b/apps/services/user-profile/src/app/types/ClientType.ts
@@ -0,0 +1,4 @@
+export enum ClientType {
+ FIRST_PARTY = 'first_party',
+ THIRD_PARTY = 'third_party',
+}
diff --git a/apps/services/user-profile/src/app/user-profile/dto/userDeviceToken.dto.ts b/apps/services/user-profile/src/app/user-profile/dto/userDeviceToken.dto.ts
index 8a6320421468..0a52209cb540 100644
--- a/apps/services/user-profile/src/app/user-profile/dto/userDeviceToken.dto.ts
+++ b/apps/services/user-profile/src/app/user-profile/dto/userDeviceToken.dto.ts
@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsDate, IsString } from 'class-validator'
import { Type } from 'class-transformer'
-import { IsNationalId } from '@island.is/nest/core'
+import { IsPersonNationalId } from '@island.is/nest/core'
const EXAMPLE_TOKEN =
'f4XghAZSRs6L-RNWRo9-Mw:APA91bFGgAc-0rhMgeHCDvkMJBH_nU4dApG6qqATliEbPs9xXf5n7EJ7FiAjJ6NNCHMBKdqHMdLrkaFHxuShzTwmZquyCjchuVMwAGmlwdXY8vZWnVqvMVItYn5lfIH-mR7Q9FvnNlhv'
@@ -16,7 +16,7 @@ export class UserDeviceTokenDto {
@ApiProperty({ required: true })
@IsString()
- @IsNationalId()
+ @IsPersonNationalId()
nationalId!: string
@ApiProperty({ required: true, example: EXAMPLE_TOKEN })
diff --git a/apps/services/user-profile/src/app/user-profile/verification.service.ts b/apps/services/user-profile/src/app/user-profile/verification.service.ts
index ed94c8ad9769..a195fa22e951 100644
--- a/apps/services/user-profile/src/app/user-profile/verification.service.ts
+++ b/apps/services/user-profile/src/app/user-profile/verification.service.ts
@@ -11,13 +11,14 @@ import { LOGGER_PROVIDER } from '@island.is/logging'
import type { Logger } from '@island.is/logging'
import { SmsService } from '@island.is/nova-sms'
-import environment from '../../environments/environment'
import { ConfirmEmailDto } from './dto/confirmEmailDto'
import { ConfirmSmsDto } from './dto/confirmSmsDto'
import { ConfirmationDtoResponse } from './dto/confirmationResponseDto'
import { CreateSmsVerificationDto } from './dto/createSmsVerificationDto'
import { EmailVerification } from './emailVerification.model'
import { SmsVerification } from './smsVerification.model'
+import { UserProfileConfig } from '../../config'
+import type { ConfigType } from '@island.is/nest/config'
/** Category to attach each log message to */
const LOG_CATEGORY = 'verification-service'
@@ -27,28 +28,28 @@ export const SMS_VERIFICATION_MAX_TRIES = 5
export const EMAIL_VERIFICATION_MAX_TRIES = 5
/**
- *- email verification procedure
- *- New user
- *- User confirms before User profile Creation
- *- Create email confirmation
- *- Confirm Directly with emailCode
- *- On profile creation check for confirmation and mark email as verified
- *- Update user
- *- Create email confirmation
- *- Confirm Directly with code
- *- update email check db for confirmation save email as verified
-
-
- *- SMS verification procedure
- *- New user
- *- User confirms before User profile Creation
- *- Create sms confirmation
- *- Confirm Directly with smsCode
- *- On profile creation check for confirmation and mark phone as verified
- *- Update user
- *- Create sms confirmation
- *- Confirm Directly with code
- *- update Phonenumber check db for confirmation save phone as verified
+ *- email verification procedure
+ *- New user
+ *- User confirms before User profile Creation
+ *- Create email confirmation
+ *- Confirm Directly with emailCode
+ *- On profile creation check for confirmation and mark email as verified
+ *- Update user
+ *- Create email confirmation
+ *- Confirm Directly with code
+ *- update email check db for confirmation save email as verified
+
+
+ *- SMS verification procedure
+ *- New user
+ *- User confirms before User profile Creation
+ *- Create sms confirmation
+ *- Confirm Directly with smsCode
+ *- On profile creation check for confirmation and mark phone as verified
+ *- Update user
+ *- Create sms confirmation
+ *- Confirm Directly with code
+ *- update Phonenumber check db for confirmation save phone as verified
*/
@Injectable()
export class VerificationService {
@@ -62,6 +63,8 @@ export class VerificationService {
private readonly smsService: SmsService,
@Inject(EmailService)
private readonly emailService: EmailService,
+ @Inject(UserProfileConfig.KEY)
+ private config: ConfigType,
) {}
async createEmailVerification(
@@ -287,8 +290,8 @@ export class VerificationService {
try {
await this.emailService.sendEmail({
from: {
- name: environment.email.fromName,
- address: environment.email.fromEmail,
+ name: this.config.email.fromName,
+ address: this.config.email.fromEmail,
},
to: [
{
diff --git a/apps/services/user-profile/src/app/v2/dto/actor-profile.dto.ts b/apps/services/user-profile/src/app/v2/dto/actor-profile.dto.ts
new file mode 100644
index 000000000000..d2df6343cbc2
--- /dev/null
+++ b/apps/services/user-profile/src/app/v2/dto/actor-profile.dto.ts
@@ -0,0 +1,67 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
+import {
+ IsBoolean,
+ IsEmail,
+ IsEnum,
+ IsNumber,
+ IsOptional,
+ IsString,
+} from 'class-validator'
+import { PageInfoDto } from '@island.is/nest/pagination'
+import { Locale } from '../../user-profile/types/localeTypes'
+
+export class MeActorProfileDto {
+ @ApiProperty()
+ @IsString()
+ readonly fromNationalId!: string
+
+ @ApiProperty()
+ @IsBoolean()
+ emailNotifications!: boolean
+}
+
+export class ActorProfileDto {
+ @ApiProperty()
+ @IsString()
+ readonly fromNationalId!: string
+
+ @ApiProperty()
+ @IsBoolean()
+ emailNotifications!: boolean
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsEmail()
+ readonly email?: string
+
+ @ApiProperty()
+ @IsBoolean()
+ readonly emailVerified!: boolean
+
+ @ApiProperty()
+ @IsBoolean()
+ readonly documentNotifications!: boolean
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsEnum(Locale)
+ readonly locale?: Locale
+}
+
+export class PatchActorProfileDto {
+ @ApiProperty()
+ @IsBoolean()
+ emailNotifications!: boolean
+}
+
+export class PaginatedActorProfileDto {
+ @ApiProperty({ type: [MeActorProfileDto] })
+ data!: MeActorProfileDto[]
+
+ @ApiProperty()
+ pageInfo!: PageInfoDto
+
+ @IsNumber()
+ @ApiProperty()
+ totalCount!: number
+}
diff --git a/apps/services/user-profile/src/app/v2/dto/user-profile.dto.ts b/apps/services/user-profile/src/app/v2/dto/user-profile.dto.ts
index ec47f60168bb..dad76399c08f 100644
--- a/apps/services/user-profile/src/app/v2/dto/user-profile.dto.ts
+++ b/apps/services/user-profile/src/app/v2/dto/user-profile.dto.ts
@@ -8,7 +8,6 @@ import {
} from 'class-validator'
import { Locale } from '../../user-profile/types/localeTypes'
-import { DataStatus } from '../../user-profile/types/dataStatusTypes'
export class UserProfileDto {
@ApiProperty()
@@ -55,4 +54,8 @@ export class UserProfileDto {
@ApiProperty()
@IsBoolean()
emailNotifications!: boolean
+
+ @ApiProperty()
+ @IsBoolean()
+ readonly isRestricted?: boolean
}
diff --git a/apps/services/user-profile/src/app/v2/me-user-profile.controller.ts b/apps/services/user-profile/src/app/v2/me-user-profile.controller.ts
index 5e4a8615d5ee..afbb96e4bd07 100644
--- a/apps/services/user-profile/src/app/v2/me-user-profile.controller.ts
+++ b/apps/services/user-profile/src/app/v2/me-user-profile.controller.ts
@@ -4,9 +4,9 @@ import {
Body,
Controller,
Get,
+ Headers,
Patch,
Post,
- Query,
UseGuards,
} from '@nestjs/common'
@@ -19,14 +19,19 @@ import {
} from '@island.is/auth-nest-tools'
import { Audit, AuditService } from '@island.is/nest/audit'
import { Documentation } from '@island.is/nest/swagger'
-import { UserProfileScope } from '@island.is/auth/scopes'
+import { ApiScope, UserProfileScope } from '@island.is/auth/scopes'
import { CreateVerificationDto } from './dto/create-verification.dto'
import { PatchUserProfileDto } from './dto/patch-user-profile.dto'
import { UserProfileDto } from './dto/user-profile.dto'
import { UserProfileService } from './user-profile.service'
-import { NudgeType } from '../types/nudge-type'
import { PostNudgeDto } from './dto/post-nudge.dto'
+import { ClientType } from '../types/ClientType'
+import {
+ MeActorProfileDto,
+ PaginatedActorProfileDto,
+ PatchActorProfileDto,
+} from './dto/actor-profile.dto'
const namespace = '@island.is/user-profile/v2/me'
@@ -53,8 +58,12 @@ export class MeUserProfileController {
@Audit({
resources: (profile) => profile.nationalId,
})
- findUserProfile(@CurrentUser() user: User): Promise {
- return this.userProfileService.findById(user.nationalId)
+ async findUserProfile(@CurrentUser() user: User): Promise {
+ return this.userProfileService.findById(
+ user.nationalId,
+ false,
+ ClientType.FIRST_PARTY,
+ )
}
@Patch()
@@ -138,4 +147,58 @@ export class MeUserProfileController {
this.userProfileService.confirmNudge(user.nationalId, input.nudgeType),
)
}
+
+ @Get('/actor-profiles')
+ @Scopes(ApiScope.internal)
+ @Documentation({
+ description: 'Get actor profiles for the current user.',
+ response: { status: 200, type: PaginatedActorProfileDto },
+ })
+ @Audit({
+ resources: (profiles) =>
+ profiles.data.map((profile) => profile.fromNationalId),
+ })
+ getActorProfiles(
+ @CurrentUser() user: User,
+ ): Promise {
+ return this.userProfileService.getActorProfiles(user.nationalId)
+ }
+
+ @Patch('/actor-profiles/.from-national-id')
+ @Scopes(ApiScope.internal)
+ @Documentation({
+ description: 'Update or create an actor profile for the current user',
+ request: {
+ header: {
+ 'X-Param-From-National-Id': {
+ required: true,
+ description: 'National id of the user that granted the delegation',
+ },
+ },
+ },
+ response: { status: 200, type: MeActorProfileDto },
+ })
+ createOrUpdateActorProfile(
+ @CurrentUser() user: User,
+ @Headers('X-Param-From-National-Id') fromNationalId: string,
+ @Body() actorProfile: PatchActorProfileDto,
+ ): Promise {
+ return this.auditService.auditPromise(
+ {
+ auth: user,
+ namespace,
+ action: 'patch',
+ resources: user.nationalId,
+ meta: {
+ fromNationalId,
+ fields: Object.keys(actorProfile),
+ },
+ },
+ this.userProfileService.createOrUpdateActorProfile({
+ toNationalId: user.nationalId,
+ fromNationalId,
+ emailNotifications: actorProfile.emailNotifications,
+ }),
+ )
+ }
}
diff --git a/apps/services/user-profile/src/app/v2/models/actor-profile.model.ts b/apps/services/user-profile/src/app/v2/models/actor-profile.model.ts
new file mode 100644
index 000000000000..98622de5e7bc
--- /dev/null
+++ b/apps/services/user-profile/src/app/v2/models/actor-profile.model.ts
@@ -0,0 +1,75 @@
+import {
+ Column,
+ DataType,
+ Model,
+ Table,
+ CreatedAt,
+ UpdatedAt,
+} from 'sequelize-typescript'
+import { ApiProperty } from '@nestjs/swagger'
+import { MeActorProfileDto } from '../dto/actor-profile.dto'
+
+@Table({
+ tableName: 'actor_profile',
+ timestamps: true,
+ indexes: [
+ {
+ fields: ['to_national_id'],
+ },
+ ],
+})
+export class ActorProfile extends Model {
+ @Column({
+ type: DataType.UUID,
+ primaryKey: true,
+ allowNull: false,
+ defaultValue: DataType.UUIDV4,
+ })
+ @ApiProperty()
+ id!: string
+
+ @Column({
+ type: DataType.STRING,
+ allowNull: false,
+ unique: {
+ name: 'actor_profile_to_from_unique',
+ msg: 'combined unique constraint failed',
+ },
+ })
+ @ApiProperty()
+ toNationalId!: string
+
+ @Column({
+ type: DataType.STRING,
+ allowNull: false,
+ unique: {
+ name: 'actor_profile_to_from_unique',
+ msg: 'combined unique constraint failed',
+ },
+ })
+ @ApiProperty()
+ fromNationalId!: string
+
+ @Column({
+ type: DataType.BOOLEAN,
+ defaultValue: true,
+ allowNull: true,
+ })
+ @ApiProperty()
+ emailNotifications!: boolean
+
+ @CreatedAt
+ @ApiProperty()
+ created!: Date
+
+ @UpdatedAt
+ @ApiProperty()
+ modified!: Date
+
+ toDto(): MeActorProfileDto {
+ return {
+ fromNationalId: this.fromNationalId,
+ emailNotifications: this.emailNotifications,
+ }
+ }
+}
diff --git a/apps/services/user-profile/src/app/v2/test/me-user-profile.controller.spec.ts b/apps/services/user-profile/src/app/v2/test/me-user-profile.controller.spec.ts
index 1d487d17d23f..96d1f54c1a8f 100644
--- a/apps/services/user-profile/src/app/v2/test/me-user-profile.controller.spec.ts
+++ b/apps/services/user-profile/src/app/v2/test/me-user-profile.controller.spec.ts
@@ -4,7 +4,7 @@ import subMonths from 'date-fns/subMonths'
import faker from 'faker'
import request, { SuperTest, Test } from 'supertest'
-import { UserProfileScope } from '@island.is/auth/scopes'
+import { ApiScope, UserProfileScope } from '@island.is/auth/scopes'
import { setupApp, TestApp } from '@island.is/testing/nest'
import {
createCurrentUser,
@@ -13,6 +13,7 @@ import {
createVerificationCode,
} from '@island.is/testing/fixtures'
import { IslyklarApi, PublicUser } from '@island.is/clients/islykill'
+import { DelegationsApi } from '@island.is/clients/auth/delegation-api'
import { FixtureFactory } from '../../../../test/fixture-factory'
import { AppModule } from '../../app.module'
@@ -26,9 +27,13 @@ import { DataStatus } from '../../user-profile/types/dataStatusTypes'
import { NudgeType } from '../../types/nudge-type'
import { PostNudgeDto } from '../dto/post-nudge.dto'
import { NUDGE_INTERVAL, SKIP_INTERVAL } from '../user-profile.service'
+import { ActorProfile } from '../models/actor-profile.model'
+import { ClientType } from '../../types/ClientType'
type StatusFieldType = 'emailStatus' | 'mobileStatus'
+const MIGRATION_DATE = new Date('2024-05-10')
+
const testUserProfile = {
nationalId: createNationalId(),
email: faker.internet.email(),
@@ -75,6 +80,7 @@ describe('MeUserProfileController', () => {
server = request(app.getHttpServer())
fixtureFactory = new FixtureFactory(app)
+
userProfileModel = app.get(getModelToken(UserProfile))
})
@@ -106,6 +112,64 @@ describe('MeUserProfileController', () => {
})
})
+ it('should return 200 with userprofile and no restrictions since lastNudge is newer then MIGRATION_DATE', async () => {
+ // Arrange
+ await fixtureFactory.createUserProfile({
+ nationalId: testUserProfile.nationalId,
+ email: testUserProfile.email,
+ emailVerified: true,
+ mobilePhoneNumber: testUserProfile.mobilePhoneNumber,
+ mobilePhoneNumberVerified: true,
+ lastNudge: addMonths(MIGRATION_DATE, 1),
+ nextNudge: subMonths(new Date(), 1),
+ })
+ // Act
+ const res = await server.get(`/v2/me`)
+
+ // Assert
+ expect(res.status).toEqual(200)
+ expect(res.body).toMatchObject({
+ nationalId: testUserProfile.nationalId,
+ email: testUserProfile.email,
+ emailVerified: true,
+ mobilePhoneNumber: testUserProfile.mobilePhoneNumber,
+ mobilePhoneNumberVerified: true,
+ locale: null,
+ documentNotifications: true,
+ needsNudge: true,
+ isRestricted: false,
+ })
+ })
+
+ it('should return 200 with userprofile and isRestricted set to true', async () => {
+ // Arrange
+ await fixtureFactory.createUserProfile({
+ nationalId: testUserProfile.nationalId,
+ email: testUserProfile.email,
+ emailVerified: true,
+ mobilePhoneNumber: testUserProfile.mobilePhoneNumber,
+ mobilePhoneNumberVerified: true,
+ lastNudge: subMonths(MIGRATION_DATE, 1),
+ nextNudge: subMonths(new Date(), 1),
+ })
+ // Act
+ const res = await server.get(`/v2/me`)
+
+ // Assert
+ expect(res.status).toEqual(200)
+ expect(res.body).toMatchObject({
+ nationalId: testUserProfile.nationalId,
+ email: testUserProfile.email,
+ emailVerified: true,
+ mobilePhoneNumber: testUserProfile.mobilePhoneNumber,
+ mobilePhoneNumberVerified: true,
+ locale: null,
+ documentNotifications: true,
+ needsNudge: true,
+ isRestricted: true,
+ })
+ })
+
const currentDate = new Date()
const expiredDate = subMonths(new Date(), NUDGE_INTERVAL + 1)
@@ -136,6 +200,7 @@ describe('MeUserProfileController', () => {
${null} | ${false} | ${null} | ${NUDGE_INTERVAL} | ${null}
${null} | ${false} | ${currentDate} | ${NUDGE_INTERVAL} | ${false}
${null} | ${false} | ${expiredDate} | ${NUDGE_INTERVAL} | ${true}
+ ${['email', 'mobilePhoneNumber']} | ${true} | ${expiredDate} | ${SKIP_INTERVAL} | ${true}
`(
'should return needsNudge=$needsNudgeExpected when $verifiedField is set and lastNudge=$lastNudge',
async ({
@@ -169,7 +234,9 @@ describe('MeUserProfileController', () => {
})
// Act
- const res = await server.get('/v2/me')
+ const res = await server.get(
+ `/v2/me?clientType=${ClientType.FIRST_PARTY}`,
+ )
// Assert
expect(res.status).toEqual(200)
@@ -1119,4 +1186,252 @@ describe('MeUserProfileController', () => {
},
)
})
+
+ describe('GET v2/me/actor-profiles', () => {
+ let app: TestApp = null
+ let server: SuperTest = null
+ let fixtureFactory: FixtureFactory = null
+ let userProfileModel: typeof UserProfile = null
+ let actorProfileModel: typeof ActorProfile = null
+ let delegationsApi: DelegationsApi = null
+
+ beforeAll(async () => {
+ app = await setupApp({
+ AppModule,
+ SequelizeConfigService,
+ user: createCurrentUser({
+ nationalId: testUserProfile.nationalId,
+ scope: [ApiScope.internal],
+ }),
+ })
+
+ server = request(app.getHttpServer())
+ fixtureFactory = new FixtureFactory(app)
+ delegationsApi = app.get(DelegationsApi)
+ userProfileModel = app.get(getModelToken(UserProfile))
+ actorProfileModel = app.get(getModelToken(ActorProfile))
+ })
+
+ beforeEach(async () => {
+ await userProfileModel.destroy({
+ truncate: true,
+ })
+ await actorProfileModel.destroy({
+ truncate: true,
+ })
+ })
+
+ afterAll(async () => {
+ await app.cleanUp()
+ })
+ it('should return 200 and the actor profile for each delegation', async () => {
+ const testNationalId1 = createNationalId('person')
+ const testNationalId2 = createNationalId('person')
+
+ // Arrange
+ jest
+ .spyOn(delegationsApi, 'delegationsControllerGetDelegationRecords')
+ .mockResolvedValue({
+ data: [
+ {
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId1,
+ subjectId: null,
+ },
+ {
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId2,
+ subjectId: null,
+ },
+ ],
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: '',
+ },
+ totalCount: 2,
+ })
+
+ // only create actor profile for one of the delegations
+ await fixtureFactory.createActorProfile({
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId1,
+ emailNotifications: false,
+ })
+
+ // Act
+ const res = await server.get('/v2/me/actor-profiles')
+
+ // Assert
+ expect(res.status).toEqual(200)
+ expect(res.body.data[0]).toStrictEqual({
+ fromNationalId: testNationalId1,
+ emailNotifications: false,
+ })
+ // Should default to true because we don't have a record for this delegation
+ expect(res.body.data[1]).toStrictEqual({
+ fromNationalId: testNationalId2,
+ emailNotifications: true,
+ })
+
+ expect(
+ delegationsApi.delegationsControllerGetDelegationRecords,
+ ).toHaveBeenCalledWith({
+ xQueryNationalId: testUserProfile.nationalId,
+ scope: '@island.is/documents',
+ direction: 'incoming',
+ })
+ })
+ })
+
+ describe('PATCH v2/me/actor-profiles/.from-national-id', () => {
+ let app: TestApp = null
+ let server: SuperTest = null
+ let fixtureFactory: FixtureFactory = null
+ let userProfileModel: typeof UserProfile = null
+ let delegationPreferenceModel: typeof ActorProfile = null
+ let delegationsApi: DelegationsApi = null
+ const testNationalId1 = createNationalId('person')
+
+ beforeAll(async () => {
+ app = await setupApp({
+ AppModule,
+ SequelizeConfigService,
+ user: createCurrentUser({
+ nationalId: testUserProfile.nationalId,
+ scope: [ApiScope.internal],
+ }),
+ })
+
+ server = request(app.getHttpServer())
+ fixtureFactory = new FixtureFactory(app)
+ delegationsApi = app.get(DelegationsApi)
+ userProfileModel = app.get(getModelToken(UserProfile))
+ delegationPreferenceModel = app.get(getModelToken(ActorProfile))
+ })
+
+ beforeEach(async () => {
+ await userProfileModel.destroy({
+ truncate: true,
+ })
+ await delegationPreferenceModel.destroy({
+ truncate: true,
+ })
+
+ jest
+ .spyOn(delegationsApi, 'delegationsControllerGetDelegationRecords')
+ .mockResolvedValue({
+ data: [
+ {
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId1,
+ subjectId: null,
+ },
+ ],
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: '',
+ },
+ totalCount: 1,
+ })
+ })
+
+ afterAll(async () => {
+ await app.cleanUp()
+ })
+
+ it('should create new actor profile for delegation if it does not exist', async () => {
+ // Act
+ const res = await server
+ .patch('/v2/me/actor-profiles/.from-national-id')
+ .set('X-Param-From-National-Id', testNationalId1)
+ .send({ emailNotifications: false })
+
+ // Assert
+ expect(res.status).toEqual(200)
+ expect(res.body).toStrictEqual({
+ fromNationalId: testNationalId1,
+ emailNotifications: false,
+ })
+
+ const actorProfile = await delegationPreferenceModel.findAll({
+ where: {
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId1,
+ },
+ })
+
+ expect(actorProfile).toHaveLength(1)
+ expect(actorProfile[0]).not.toBeNull()
+ expect(actorProfile[0].emailNotifications).toBe(false)
+ expect(
+ delegationsApi.delegationsControllerGetDelegationRecords,
+ ).toHaveBeenCalledWith({
+ xQueryNationalId: testUserProfile.nationalId,
+ scope: '@island.is/documents',
+ direction: 'incoming',
+ })
+ })
+
+ it('should update existing actor profile', async () => {
+ // Arrange
+ await fixtureFactory.createActorProfile({
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId1,
+ emailNotifications: true,
+ })
+
+ // Act
+ const res = await server
+ .patch('/v2/me/actor-profiles/.from-national-id')
+ .set('X-Param-From-National-Id', testNationalId1)
+ .send({ emailNotifications: false })
+
+ // Assert
+ expect(res.status).toEqual(200)
+ expect(res.body).toStrictEqual({
+ fromNationalId: testNationalId1,
+ emailNotifications: false,
+ })
+
+ const actorProfile = await delegationPreferenceModel.findAll({
+ where: {
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId1,
+ },
+ })
+
+ expect(actorProfile).toHaveLength(1)
+ expect(actorProfile[0]).not.toBeNull()
+ expect(actorProfile[0].emailNotifications).toBe(false)
+ })
+
+ it('should throw no content exception if delegation is not found', async () => {
+ // Arrange
+ jest
+ .spyOn(delegationsApi, 'delegationsControllerGetDelegationRecords')
+ .mockResolvedValue({
+ data: [],
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: '',
+ },
+ totalCount: 1,
+ })
+
+ // Act
+ const res = await server
+ .patch('/v2/me/actor-profiles/.from-national-id')
+ .set('X-Param-From-National-Id', testNationalId1)
+ .send({ emailNotifications: false })
+
+ // Assert
+ expect(res.status).toEqual(204)
+ })
+ })
})
diff --git a/apps/services/user-profile/src/app/v2/test/user-profile.controller.spec.ts b/apps/services/user-profile/src/app/v2/test/user-profile.controller.spec.ts
index 0e42fedb164a..2e18e8408ae7 100644
--- a/apps/services/user-profile/src/app/v2/test/user-profile.controller.spec.ts
+++ b/apps/services/user-profile/src/app/v2/test/user-profile.controller.spec.ts
@@ -1,5 +1,8 @@
import request from 'supertest'
import faker from 'faker'
+import { getModelToken } from '@nestjs/sequelize'
+import subMonths from 'date-fns/subMonths'
+import addMonths from 'date-fns/addMonths'
import {
createCurrentUser,
@@ -8,19 +11,26 @@ import {
} from '@island.is/testing/fixtures'
import { UserProfileScope } from '@island.is/auth/scopes'
import { setupApp, setupAppWithoutAuth } from '@island.is/testing/nest'
+import { DelegationsApi } from '@island.is/clients/auth/delegation-api'
import { AppModule } from '../../app.module'
import { SequelizeConfigService } from '../../sequelizeConfig.service'
import { FixtureFactory } from '../../../../test/fixture-factory'
import { UserProfile } from '../../user-profile/userProfile.model'
-import { getModelToken } from '@nestjs/sequelize'
+import { ClientType } from '../../types/ClientType'
+import { ActorProfile } from '../models/actor-profile.model'
const testUserProfile = {
nationalId: createNationalId(),
email: faker.internet.email(),
mobilePhoneNumber: createPhoneNumber(),
+ emailVerified: false,
+ documentNotifications: true,
+ locale: 'is',
}
+const MIGRATION_DATE = new Date('2024-05-10')
+
describe('UserProfileController', () => {
describe('No auth', () => {
it('GET /v2/users/.national-id should return 401 when user is not authenticated', async () => {
@@ -81,6 +91,8 @@ describe('UserProfileController', () => {
let server = null
let fixtureFactory = null
let userProfileModel: typeof UserProfile = null
+ let actorProfileModel: typeof ActorProfile = null
+ let delegationsApi: DelegationsApi = null
beforeAll(async () => {
app = await setupApp({
@@ -94,12 +106,17 @@ describe('UserProfileController', () => {
server = request(app.getHttpServer())
fixtureFactory = new FixtureFactory(app)
userProfileModel = app.get(getModelToken(UserProfile))
+ delegationsApi = app.get(DelegationsApi)
+ actorProfileModel = app.get(getModelToken(ActorProfile))
})
beforeEach(async () => {
await userProfileModel.destroy({
truncate: true,
})
+ await actorProfileModel.destroy({
+ truncate: true,
+ })
})
afterAll(async () => {
@@ -124,12 +141,15 @@ describe('UserProfileController', () => {
})
})
- it('GET /v2/user/.national-id should return 200 with the UserProfileDto when the User Profile exists in db', async () => {
+ it('GET /v2/user/.national-id should return 200 with the UserProfileDto with email and phone number when client type is firstParty', async () => {
// Arrange
- await fixtureFactory.createUserProfile(testUserProfile)
+ await fixtureFactory.createUserProfile({
+ ...testUserProfile,
+ lastNudge: subMonths(MIGRATION_DATE, 1),
+ })
const res = await server
- .get('/v2/users/.national-id')
+ .get(`/v2/users/.national-id?clientType=${ClientType.FIRST_PARTY}`)
.set('X-Param-National-Id', testUserProfile.nationalId)
// Assert
@@ -142,6 +162,186 @@ describe('UserProfileController', () => {
mobilePhoneNumberVerified: false,
documentNotifications: true,
needsNudge: null,
+ isRestricted: true,
+ })
+ })
+
+ it('GET /v2/user/.national-id should return 200 with the UserProfileDto without email and phone when client type is thirdParty', async () => {
+ // Arrange
+ await fixtureFactory.createUserProfile({
+ ...testUserProfile,
+ lastNudge: subMonths(MIGRATION_DATE, 1),
+ })
+
+ const res = await server
+ .get(`/v2/users/.national-id?clientType=${ClientType.THIRD_PARTY}`)
+ .set('X-Param-National-Id', testUserProfile.nationalId)
+
+ // Assert
+ expect(res.status).toEqual(200)
+ expect(res.body).toMatchObject({
+ nationalId: testUserProfile.nationalId,
+ email: null,
+ emailVerified: false,
+ mobilePhoneNumber: null,
+ mobilePhoneNumberVerified: false,
+ documentNotifications: true,
+ needsNudge: null,
+ })
+ })
+
+ it('GET /v2/user/.national-id should return 200 with the UserProfileDto with the email and phone when client type is thirdParty and last nudge is more recent then the migration date', async () => {
+ // Arrange
+ await fixtureFactory.createUserProfile({
+ ...testUserProfile,
+ lastNudge: addMonths(MIGRATION_DATE, 1),
+ })
+
+ const res = await server
+ .get(`/v2/users/.national-id?clientType=${ClientType.THIRD_PARTY}`)
+ .set('X-Param-National-Id', testUserProfile.nationalId)
+
+ // Assert
+ expect(res.status).toEqual(200)
+ expect(res.body).toMatchObject({
+ nationalId: testUserProfile.nationalId,
+ email: testUserProfile.email,
+ emailVerified: false,
+ mobilePhoneNumber: testUserProfile.mobilePhoneNumber,
+ mobilePhoneNumberVerified: false,
+ documentNotifications: true,
+ needsNudge: null,
+ isRestricted: false,
+ })
+ })
+
+ describe('GET /v2/users/.to-national-id/actor-profiles/.from-national-id', () => {
+ const testNationalId1 = createNationalId('person')
+ const testNationalId2 = createNationalId('person')
+
+ beforeEach(async () => {
+ jest
+ .spyOn(delegationsApi, 'delegationsControllerGetDelegationRecords')
+ .mockResolvedValue({
+ data: [
+ {
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId1,
+ subjectId: null,
+ },
+ {
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId2,
+ subjectId: null,
+ },
+ ],
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: '',
+ },
+ totalCount: 2,
+ })
+ })
+
+ it('should return 200 and the extended actor profile if user profile exists', async () => {
+ await fixtureFactory.createUserProfile(testUserProfile)
+
+ await fixtureFactory.createActorProfile({
+ toNationalId: testUserProfile.nationalId,
+ fromNationalId: testNationalId1,
+ emailNotifications: false,
+ })
+
+ // Act
+ const res = await server
+ .get('/v2/users/.to-national-id/actor-profiles/.from-national-id')
+ .set('X-Param-To-National-Id', testUserProfile.nationalId)
+ .set('X-Param-From-National-Id', testNationalId1)
+
+ // Assert
+ expect(res.status).toEqual(200)
+ expect(res.body).toStrictEqual({
+ fromNationalId: testNationalId1,
+ emailNotifications: false,
+ email: testUserProfile.email,
+ emailVerified: testUserProfile.emailVerified,
+ documentNotifications: testUserProfile.documentNotifications,
+ locale: testUserProfile.locale,
+ })
+
+ expect(
+ delegationsApi.delegationsControllerGetDelegationRecords,
+ ).toHaveBeenCalledWith({
+ xQueryNationalId: testUserProfile.nationalId,
+ scope: '@island.is/documents',
+ direction: 'incoming',
+ })
+ })
+
+ it('should return 200 and the extended actor profile if user profile exists, should default to emailNotifications = true', async () => {
+ // Arrange
+ await fixtureFactory.createUserProfile(testUserProfile)
+
+ // Act
+ const res = await server
+ .get('/v2/users/.to-national-id/actor-profiles/.from-national-id')
+ .set('X-Param-To-National-Id', testUserProfile.nationalId)
+ .set('X-Param-From-National-Id', testNationalId1)
+
+ // Assert
+ expect(res.status).toEqual(200)
+ expect(res.body).toStrictEqual({
+ fromNationalId: testNationalId1,
+ emailNotifications: true,
+ email: testUserProfile.email,
+ emailVerified: testUserProfile.emailVerified,
+ documentNotifications: testUserProfile.documentNotifications,
+ locale: testUserProfile.locale,
+ })
+ })
+
+ it('should return 400 if toNationalId is invalid', async () => {
+ // Arrange
+ await fixtureFactory.createUserProfile(testUserProfile)
+
+ // Act
+ const res = await server
+ .get('/v2/users/.to-national-id/actor-profiles/.from-national-id')
+ .set('X-Param-To-National-Id', 'invalid')
+ .set('X-Param-From-National-Id', testNationalId1)
+
+ // Assert
+ expect(res.status).toEqual(400)
+ })
+
+ it('should return 400 if fromNationalId is invalid', async () => {
+ // Arrange
+ await fixtureFactory.createUserProfile(testUserProfile)
+
+ // Act
+ const res = await server
+ .get('/v2/users/.to-national-id/actor-profiles/.from-national-id')
+ .set('X-Param-To-National-Id', 'invalid')
+ .set('X-Param-From-National-Id', testNationalId1)
+
+ // Assert
+ expect(res.status).toEqual(400)
+ })
+
+ it('should return 400 if delegation does not exist', async () => {
+ // Arrange
+ await fixtureFactory.createUserProfile(testUserProfile)
+
+ // Act
+ const res = await server
+ .get('/v2/users/.to-national-id/actor-profiles/.from-national-id')
+ .set('X-Param-To-National-Id', testUserProfile.nationalId)
+ .set('X-Param-From-National-Id', createNationalId('person'))
+
+ // Assert
+ expect(res.status).toEqual(400)
})
})
})
diff --git a/apps/services/user-profile/src/app/v2/user-profile.controller.ts b/apps/services/user-profile/src/app/v2/user-profile.controller.ts
index 1efd48399c56..a8525255cd18 100644
--- a/apps/services/user-profile/src/app/v2/user-profile.controller.ts
+++ b/apps/services/user-profile/src/app/v2/user-profile.controller.ts
@@ -4,6 +4,7 @@ import {
Controller,
Get,
Headers,
+ Query,
UseGuards,
} from '@nestjs/common'
import * as kennitala from 'kennitala'
@@ -15,6 +16,8 @@ import { IdsAuthGuard, Scopes, ScopesGuard } from '@island.is/auth-nest-tools'
import { UserProfileDto } from './dto/user-profile.dto'
import { UserProfileService } from './user-profile.service'
+import { ClientType } from '../types/ClientType'
+import { ActorProfileDto } from './dto/actor-profile.dto'
const namespace = '@island.is/user-profile/v2/users'
@@ -40,6 +43,13 @@ export class UserProfileController {
description: 'National id of the user to find',
},
},
+ query: {
+ clientType: {
+ required: false,
+ description: 'Client type',
+ enum: ClientType,
+ },
+ },
},
response: { status: 200, type: UserProfileDto },
})
@@ -48,10 +58,48 @@ export class UserProfileController {
})
async findUserProfile(
@Headers('X-Param-National-Id') nationalId: string,
+ @Query('clientType') clientType: ClientType = ClientType.THIRD_PARTY,
): Promise {
if (!kennitala.isValid(nationalId)) {
throw new BadRequestException('National id is not valid')
}
- return this.userProfileService.findById(nationalId)
+ return this.userProfileService.findById(nationalId, false, clientType)
+ }
+
+ @Get('/.to-national-id/actor-profiles/.from-national-id')
+ @Documentation({
+ description: 'Get actor profiles for nationalId.',
+ request: {
+ header: {
+ 'X-Param-To-National-Id': {
+ required: true,
+ description: 'National id of the user the actor profile is for',
+ },
+ 'X-Param-From-National-Id': {
+ required: true,
+ description: 'National id of the user the delegation is from',
+ },
+ },
+ },
+ response: { status: 200, type: ActorProfileDto },
+ })
+ @Audit({
+ resources: (profile) => profile.fromNationalId,
+ })
+ async getActorProfile(
+ @Headers('X-Param-To-National-Id') toNationalId: string,
+ @Headers('X-Param-From-National-Id') fromNationalId: string,
+ ): Promise {
+ if (
+ !kennitala.isValid(toNationalId) ||
+ !kennitala.isValid(fromNationalId)
+ ) {
+ throw new BadRequestException('National id is not valid')
+ }
+
+ return this.userProfileService.getActorProfile({
+ toNationalId,
+ fromNationalId,
+ })
}
}
diff --git a/apps/services/user-profile/src/app/v2/user-profile.module.ts b/apps/services/user-profile/src/app/v2/user-profile.module.ts
index 940137083752..3596571f5236 100644
--- a/apps/services/user-profile/src/app/v2/user-profile.module.ts
+++ b/apps/services/user-profile/src/app/v2/user-profile.module.ts
@@ -17,6 +17,8 @@ import { UserProfileController } from './user-profile.controller'
import { UserTokenController } from './userToken.controller'
import { UserTokenService } from './userToken.service'
import { UserDeviceTokens } from '../user-profile/userDeviceTokens.model'
+import { ActorProfile } from './models/actor-profile.model'
+import { AuthDelegationApiClientModule } from '@island.is/clients/auth/delegation-api'
@Module({
imports: [
@@ -25,6 +27,7 @@ import { UserDeviceTokens } from '../user-profile/userDeviceTokens.model'
EmailVerification,
SmsVerification,
UserDeviceTokens,
+ ActorProfile,
]),
EmailModule.register(environment.emailOptions),
SmsModule.register(environment.smsOptions),
@@ -33,6 +36,7 @@ import { UserDeviceTokens } from '../user-profile/userDeviceTokens.model'
cert: environment.islykillConfig.cert,
passphrase: environment.islykillConfig.passphrase,
}),
+ AuthDelegationApiClientModule,
],
controllers: [
MeUserProfileController,
diff --git a/apps/services/user-profile/src/app/v2/user-profile.service.ts b/apps/services/user-profile/src/app/v2/user-profile.service.ts
index 675a218193a5..1481db2b0e08 100644
--- a/apps/services/user-profile/src/app/v2/user-profile.service.ts
+++ b/apps/services/user-profile/src/app/v2/user-profile.service.ts
@@ -5,8 +5,12 @@ import addMonths from 'date-fns/addMonths'
import { Sequelize } from 'sequelize-typescript'
import { isDefined } from '@island.is/shared/utils'
-import { AttemptFailed } from '@island.is/nest/problem'
+import { AttemptFailed, NoContentException } from '@island.is/nest/problem'
import type { User } from '@island.is/auth-nest-tools'
+import {
+ DelegationsApi,
+ DelegationsControllerGetDelegationRecordsDirectionEnum,
+} from '@island.is/clients/auth/delegation-api'
import { VerificationService } from '../user-profile/verification.service'
import { UserProfile } from '../user-profile/userProfile.model'
@@ -16,6 +20,16 @@ import { UserProfileDto } from './dto/user-profile.dto'
import { IslykillService } from './islykill.service'
import { DataStatus } from '../user-profile/types/dataStatusTypes'
import { NudgeType } from '../types/nudge-type'
+import { ClientType } from '../types/ClientType'
+import { UserProfileConfig } from '../../config'
+import type { ConfigType } from '@island.is/nest/config'
+import { ActorProfile } from './models/actor-profile.model'
+import {
+ ActorProfileDto,
+ MeActorProfileDto,
+ PaginatedActorProfileDto,
+} from './dto/actor-profile.dto'
+import { DocumentsScope } from '@island.is/auth/scopes'
export const NUDGE_INTERVAL = 6
export const SKIP_INTERVAL = 1
@@ -25,15 +39,21 @@ export class UserProfileService {
constructor(
@InjectModel(UserProfile)
private readonly userProfileModel: typeof UserProfile,
+ @InjectModel(ActorProfile)
+ private readonly delegationPreference: typeof ActorProfile,
@Inject(VerificationService)
private readonly verificationService: VerificationService,
private readonly islykillService: IslykillService,
private sequelize: Sequelize,
+ @Inject(UserProfileConfig.KEY)
+ private config: ConfigType,
+ private readonly delegationsApi: DelegationsApi,
) {}
async findById(
nationalId: string,
useMaster = false,
+ clientType: ClientType = ClientType.THIRD_PARTY,
): Promise {
const userProfile = await this.userProfileModel.findOne({
where: { nationalId },
@@ -51,20 +71,11 @@ export class UserProfileService {
documentNotifications: true,
needsNudge: null,
emailNotifications: true,
+ isRestricted: false,
}
}
- return {
- nationalId: userProfile.nationalId,
- email: userProfile.email,
- mobilePhoneNumber: userProfile.mobilePhoneNumber,
- locale: userProfile.locale,
- mobilePhoneNumberVerified: userProfile.mobilePhoneNumberVerified,
- emailVerified: userProfile.emailVerified,
- documentNotifications: userProfile.documentNotifications,
- needsNudge: this.checkNeedsNudge(userProfile),
- emailNotifications: userProfile.emailNotifications,
- }
+ return this.filterByClientTypeAndRestrictionDate(clientType, userProfile)
}
async patch(
@@ -234,7 +245,7 @@ export class UserProfileService {
}
})
- return this.findById(nationalId, true)
+ return this.findById(nationalId, true, ClientType.FIRST_PARTY)
}
async createEmailVerification({
@@ -302,6 +313,121 @@ export class UserProfileService {
})
}
+ /* fetch actor profiles (delegation preferences) for each delegation */
+ async getActorProfiles(
+ toNationalId: string,
+ ): Promise {
+ const incomingDelegations = await this.getIncomingDelegations(toNationalId)
+
+ const emailPreferences = await this.delegationPreference.findAll({
+ where: {
+ toNationalId,
+ fromNationalId: incomingDelegations.data.map((d) => d.fromNationalId),
+ },
+ })
+
+ const actorProfiles = incomingDelegations.data.map((delegation) => {
+ const emailPreference = emailPreferences.find(
+ (preference) => preference.fromNationalId === delegation.fromNationalId,
+ )
+
+ // return email preference if it exists, otherwise return default true
+ return (
+ emailPreference?.toDto() ?? {
+ fromNationalId: delegation.fromNationalId,
+ emailNotifications: true,
+ }
+ )
+ })
+
+ return {
+ data: actorProfiles,
+ totalCount: actorProfiles.length,
+ pageInfo: {
+ hasNextPage: false,
+ },
+ }
+ }
+
+ /* Fetch extended actor profile for a specific delegation */
+ async getActorProfile({
+ toNationalId,
+ fromNationalId,
+ }: {
+ fromNationalId: string
+ toNationalId: string
+ }): Promise {
+ const incomingDelegation = await this.getIncomingDelegations(toNationalId)
+
+ const delegation = incomingDelegation.data.find(
+ (d) => d.fromNationalId === fromNationalId,
+ )
+
+ if (!delegation) {
+ throw new BadRequestException('delegation does not exist')
+ }
+
+ const userProfile = await this.findById(
+ toNationalId,
+ false,
+ ClientType.FIRST_PARTY,
+ )
+
+ const emailPreferences = await this.delegationPreference.findOne({
+ where: {
+ toNationalId,
+ fromNationalId,
+ },
+ })
+
+ return {
+ fromNationalId,
+ emailNotifications: emailPreferences?.emailNotifications ?? true,
+ email: userProfile.email,
+ emailVerified: userProfile.emailVerified,
+ documentNotifications: userProfile.documentNotifications,
+ locale: userProfile.locale,
+ }
+ }
+
+ async createOrUpdateActorProfile({
+ toNationalId,
+ fromNationalId,
+ emailNotifications,
+ }: {
+ toNationalId: string
+ fromNationalId: string
+ emailNotifications: boolean
+ }): Promise {
+ const incomingDelegations = await this.getIncomingDelegations(toNationalId)
+
+ // if the delegation does not exist, throw an error
+ if (
+ !incomingDelegations.data.some((d) => d.fromNationalId === fromNationalId)
+ ) {
+ throw new NoContentException()
+ }
+
+ const [profile] = await this.delegationPreference.upsert({
+ toNationalId,
+ fromNationalId,
+ emailNotifications,
+ })
+
+ return profile.toDto()
+ }
+
+ /* Private methods */
+
+ private async getIncomingDelegations(nationalId: string) {
+ return this.delegationsApi.delegationsControllerGetDelegationRecords({
+ xQueryNationalId: nationalId,
+ scope: DocumentsScope.main,
+ direction:
+ DelegationsControllerGetDelegationRecordsDirectionEnum.incoming,
+ })
+ }
+
private checkNeedsNudge(userProfile: UserProfile): boolean | null {
if (userProfile.nextNudge) {
if (!userProfile.email && !userProfile.mobilePhoneNumber) {
@@ -375,4 +501,38 @@ export class UserProfileService {
audkenniSimNumber.replace(/-/g, '').slice(-7)
)
}
+
+ filterByClientTypeAndRestrictionDate(
+ clientType: ClientType,
+ userProfile: UserProfile,
+ ): UserProfileDto {
+ const isFirstParty = clientType === ClientType.FIRST_PARTY
+ let filteredUserProfile: UserProfileDto = {
+ nationalId: userProfile.nationalId,
+ email: userProfile.email,
+ mobilePhoneNumber: userProfile.mobilePhoneNumber,
+ locale: userProfile.locale,
+ mobilePhoneNumberVerified: userProfile.mobilePhoneNumberVerified,
+ emailVerified: userProfile.emailVerified,
+ documentNotifications: userProfile.documentNotifications,
+ needsNudge: this.checkNeedsNudge(userProfile),
+ emailNotifications: userProfile.emailNotifications,
+ isRestricted: false,
+ }
+
+ if ((this.config.migrationDate ?? new Date()) > userProfile.lastNudge) {
+ filteredUserProfile = {
+ ...filteredUserProfile,
+ email: isFirstParty ? userProfile.email : null,
+ mobilePhoneNumber: isFirstParty ? userProfile.mobilePhoneNumber : null,
+ emailVerified: isFirstParty ? userProfile.emailVerified : false,
+ mobilePhoneNumberVerified: isFirstParty
+ ? userProfile.mobilePhoneNumberVerified
+ : false,
+ isRestricted: true,
+ }
+ }
+
+ return filteredUserProfile
+ }
}
diff --git a/apps/services/user-profile/src/app/worker/worker.module.ts b/apps/services/user-profile/src/app/worker/worker.module.ts
index e855e24b9213..b58e54a66566 100644
--- a/apps/services/user-profile/src/app/worker/worker.module.ts
+++ b/apps/services/user-profile/src/app/worker/worker.module.ts
@@ -10,10 +10,16 @@ import { environment } from '../../environments'
import { UserProfileWorkerService } from './worker.service'
import { UserProfile } from '../user-profile/userProfile.model'
import { UserProfileAdvania } from './userProfileAdvania.model'
+import { UserProfileConfig } from '../../config'
+import { ConfigModule } from '@island.is/nest/config'
@Module({
imports: [
AuditModule.forRoot(environment.audit),
+ ConfigModule.forRoot({
+ isGlobal: true,
+ load: [UserProfileConfig],
+ }),
LoggingModule,
SequelizeModule.forRootAsync({
useClass: SequelizeConfigService,
diff --git a/apps/services/user-profile/src/app/worker/worker.service.ts b/apps/services/user-profile/src/app/worker/worker.service.ts
index 2fc61bfb39f3..8236ee4e9cd1 100644
--- a/apps/services/user-profile/src/app/worker/worker.service.ts
+++ b/apps/services/user-profile/src/app/worker/worker.service.ts
@@ -1,8 +1,11 @@
import formatDistance from 'date-fns/formatDistance'
-import { Injectable } from '@nestjs/common'
+import { Inject, Injectable } from '@nestjs/common'
+import addMonths from 'date-fns/addMonths'
import { logger } from '@island.is/logging'
+import type { ConfigType } from '@island.is/nest/config'
+
import { InjectModel } from '@nestjs/sequelize'
import { UserProfile } from '../user-profile/userProfile.model'
import { UserProfileAdvania } from './userProfileAdvania.model'
@@ -11,9 +14,8 @@ import {
chooseEmailAndPhoneNumberFields,
hasMatchingContactInfo,
} from './worker.utils'
-import { environment } from '../../environments'
-import addMonths from 'date-fns/addMonths'
import { NUDGE_INTERVAL } from '../v2/user-profile.service'
+import { UserProfileConfig } from '../../config'
/**
* The purpose of this worker is to import user profiles from Advania
@@ -25,6 +27,8 @@ export class UserProfileWorkerService {
private readonly userProfileModel: typeof UserProfile,
@InjectModel(UserProfileAdvania)
private readonly userProfileAdvaniaModel: typeof UserProfileAdvania,
+ @Inject(UserProfileConfig.KEY)
+ private config: ConfigType,
) {}
public async run() {
@@ -100,10 +104,10 @@ export class UserProfileWorkerService {
logger.info(`${numberOfProfilesToProcess} profiles to process`)
const numberOfPagesToProcess = Math.ceil(
- numberOfProfilesToProcess / environment.worker.processPageSize,
+ numberOfProfilesToProcess / this.config.workerProcessPageSize,
)
logger.info(
- `splitting work into ${numberOfPagesToProcess} pages with page_size=${environment.worker.processPageSize}`,
+ `splitting work into ${numberOfPagesToProcess} pages with page_size=${this.config.workerProcessPageSize}`,
)
const startTime = Date.now()
@@ -124,7 +128,7 @@ export class UserProfileWorkerService {
where: {
status: ProcessedStatus.PENDING,
},
- limit: environment.worker.processPageSize,
+ limit: this.config.workerProcessPageSize,
})
const userProfiles = await this.userProfileModel.findAll({
@@ -198,7 +202,7 @@ export class UserProfileWorkerService {
) {
const timeElapsed = Date.now() - startTime
const profilesProcessed =
- (currentPageIndex + 1) * environment.worker.processPageSize
+ (currentPageIndex + 1) * this.config.workerProcessPageSize
const msPerProfile = timeElapsed / profilesProcessed
const timeRemaining =
(numberOfProfilesToProcess - profilesProcessed) * msPerProfile
diff --git a/apps/services/user-profile/src/config.ts b/apps/services/user-profile/src/config.ts
new file mode 100644
index 000000000000..7b8e2caefebf
--- /dev/null
+++ b/apps/services/user-profile/src/config.ts
@@ -0,0 +1,36 @@
+import { defineConfig } from '@island.is/nest/config'
+import { z } from 'zod'
+
+const schema = z.object({
+ migrationDate: z.date(),
+ workerProcessPageSize: z.number(),
+ email: z.object({
+ fromEmail: z.string(),
+ fromName: z.string(),
+ servicePortalBaseUrl: z.string(),
+ }),
+})
+
+export const UserProfileConfig = defineConfig({
+ name: 'UserProfileApi',
+ schema,
+ load(env) {
+ return {
+ migrationDate: new Date(
+ env.optional('USER_PROFILE_MIGRATION_DATE') ?? '2024-04-12',
+ ),
+ workerProcessPageSize: env.optionalJSON(
+ 'USER_PROFILE_WORKER_PAGE_SIZE',
+ 3000,
+ ),
+ email: {
+ fromEmail: env.required('EMAIL_FROM', 'noreply@island.is'),
+ fromName: env.required('EMAIL_FROM_NAME', 'island.is'),
+ servicePortalBaseUrl: env.required(
+ 'SERVICE_PORTAL_BASE_URL',
+ 'http://localhost:4200',
+ ),
+ },
+ }
+ },
+})
diff --git a/apps/services/user-profile/src/environments/environment.ts b/apps/services/user-profile/src/environments/environment.ts
index ace350785884..06e8a554a3eb 100644
--- a/apps/services/user-profile/src/environments/environment.ts
+++ b/apps/services/user-profile/src/environments/environment.ts
@@ -1,14 +1,6 @@
const devConfig = {
production: false,
port: 3366,
- email: {
- fromEmail: 'noreply@island.is',
- fromName: 'island.is',
- replyToEmail: 'noreply@island.is',
- replyToName: 'island.is',
- servicePortalBaseUrl:
- process.env.SERVICE_PORTAL_BASE_URL ?? 'http://localhost:4200',
- },
smsOptions: {
url: 'https://smsapi.devnova.is',
username: 'IslandIs_User_Development',
@@ -33,21 +25,11 @@ const devConfig = {
'https://identity-server.dev01.devland.is',
audience: '@island.is',
},
- worker: {
- processPageSize: process.env.USER_PROFILE_WORKER_PAGE_SIZE
- ? Number(process.env.USER_PROFILE_WORKER_PAGE_SIZE)
- : 3000,
- },
}
const prodConfig = {
production: true,
port: 3333,
- email: {
- fromEmail: process.env.EMAIL_FROM,
- fromName: process.env.EMAIL_FROM_NAME,
- servicePortalBaseUrl: process.env.SERVICE_PORTAL_BASE_URL,
- },
smsOptions: {
url: process.env.NOVA_URL,
username: process.env.NOVA_USERNAME,
@@ -74,11 +56,6 @@ const prodConfig = {
issuer: process.env.IDENTITY_SERVER_ISSUER_URL,
audience: '@island.is',
},
- worker: {
- processPageSize: process.env.USER_PROFILE_WORKER_PAGE_SIZE
- ? Number(process.env.USER_PROFILE_WORKER_PAGE_SIZE)
- : 3000,
- },
}
export default process.env.PROD_MODE === 'true' ||
diff --git a/apps/services/user-profile/test/fixture-factory.ts b/apps/services/user-profile/test/fixture-factory.ts
index 3c98debbd526..eedf200b59b6 100644
--- a/apps/services/user-profile/test/fixture-factory.ts
+++ b/apps/services/user-profile/test/fixture-factory.ts
@@ -8,6 +8,7 @@ import { SmsVerification } from '../src/app/user-profile/smsVerification.model'
import { UserProfile } from '../src/app/user-profile/userProfile.model'
import { UserDeviceTokens } from '../src/app/user-profile/userDeviceTokens.model'
import { DataStatus } from '../src/app/user-profile/types/dataStatusTypes'
+import { ActorProfile } from '../src/app/v2/models/actor-profile.model'
export class FixtureFactory {
constructor(private app: TestApp) {}
@@ -81,4 +82,22 @@ export class FixtureFactory {
deviceToken,
})
}
+
+ async createActorProfile({
+ toNationalId,
+ fromNationalId,
+ emailNotifications,
+ }: {
+ toNationalId: string
+ fromNationalId: string
+ emailNotifications: boolean
+ }) {
+ const actorProfileModel = this.get(ActorProfile)
+
+ return actorProfileModel.create({
+ toNationalId,
+ fromNationalId,
+ emailNotifications,
+ })
+ }
}
diff --git a/apps/system-e2e/README.md b/apps/system-e2e/README.md
index d1f0cbb6351a..c783109ae982 100644
--- a/apps/system-e2e/README.md
+++ b/apps/system-e2e/README.md
@@ -193,7 +193,7 @@ You will need a few things to set up your test so it can run with mountebank.
Now that you are set up. You need to run a couple of commands.
-- In your terminal run `yarn infra render-local-env --service=service-portal --service=api` .
+- In your terminal run `yarn cli render-local-env --service=service-portal --service=api` .
- This would show you commands how to start the mocking for `service-portal` and `api`. Replace with the services you want to test.
- In the output you will see a docker output it will look something like this: `docker run -it --rm -p ...` copy that line and run in a new terminal window. Now your Mountebank impostor should be running.
- Now start your services, but make sure your services ports have been replaced by the ports provided by Mountebank. In this examples case that would be `XROAD_BASE_PATH=http://localhost:9388 yarn start api`
diff --git a/apps/system-e2e/src/tests/islandis/service-portal/acceptance/health.spec.ts b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/health.spec.ts
new file mode 100644
index 000000000000..38573f8ee58c
--- /dev/null
+++ b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/health.spec.ts
@@ -0,0 +1,144 @@
+import { test, BrowserContext, expect } from '@playwright/test'
+import { icelandicAndNoPopupUrl, urls } from '../../../../support/urls'
+import { session } from '../../../../support/session'
+import { label } from '../../../../support/i18n'
+import { messages } from '@island.is/service-portal/health/messages'
+import { disableI18n } from '../../../../support/disablers'
+import { setupXroadMocks } from './setup-xroad.mocks'
+
+const homeUrl = `${urls.islandisBaseUrl}/minarsidur`
+test.use({ baseURL: urls.islandisBaseUrl })
+
+test.describe('MS - Health', () => {
+ let context: BrowserContext
+
+ test.beforeAll(async ({ browser }) => {
+ context = await session({
+ browser: browser,
+ storageState: 'service-portal-faereyjar.json',
+ homeUrl,
+ phoneNumber: '0102399',
+ idsLoginOn: true,
+ })
+ })
+
+ test.afterAll(async () => {
+ await context.close()
+ })
+
+ test('dentists', async () => {
+ const page = await context.newPage()
+ await disableI18n(page)
+
+ await test.step('should display data', async () => {
+ // Arrange
+ await page.goto(icelandicAndNoPopupUrl('/minarsidur/heilsa/tannlaeknar'))
+
+ const title = page.getByRole('heading', {
+ name: 'Tannlæknar',
+ })
+ await expect(title).toBeVisible()
+ })
+ })
+
+ test('dentist registration', async () => {
+ const page = await context.newPage()
+ await setupXroadMocks()
+ await disableI18n(page)
+
+ await test.step('should display registration button', async () => {
+ // Arrange
+ await page.goto(icelandicAndNoPopupUrl('/minarsidur/heilsa/tannlaeknar'))
+
+ const title = page.getByRole('link', {
+ name: label(messages.changeRegistration),
+ })
+
+ await expect(title).toBeVisible()
+ await title.click()
+
+ const row = page.getByRole('row').last()
+ await row.hover()
+
+ const save = page.getByRole('button', {
+ name: label(messages.healthRegistrationSave),
+ })
+
+ await expect(save).toBeVisible()
+ await save.click()
+
+ const agreeButton = page.getByRole('button', {
+ name: label(messages.healthRegisterModalAccept),
+ })
+
+ await expect(agreeButton).toBeVisible()
+ await agreeButton.click()
+
+ const newDentist = page.getByRole('heading', {
+ name: 'Nýr tannlæknir skráður',
+ })
+
+ await expect(newDentist).toBeVisible()
+ })
+ })
+
+ test('health centers', async () => {
+ const page = await context.newPage()
+ await disableI18n(page)
+
+ await test.step('should display data', async () => {
+ // Arrange
+ await page.goto(icelandicAndNoPopupUrl('/minarsidur/heilsa/heilsugaesla'))
+
+ const title = page.getByRole('heading', {
+ name: 'Heilsugæsla',
+ })
+ await expect(title).toBeVisible()
+ })
+ })
+
+ test('health center registration', async () => {
+ const page = await context.newPage()
+ await setupXroadMocks()
+ await disableI18n(page)
+
+ await test.step('should display registration button', async () => {
+ // Arrange
+ await page.goto(icelandicAndNoPopupUrl('/minarsidur/heilsa/heilsugaesla'))
+
+ const title = page.getByRole('link', {
+ name: label(messages.changeRegistration),
+ })
+
+ await expect(title).toBeVisible()
+ await title.click()
+
+ await page.getByTestId('accordion-item').first().click()
+
+ const row = page.getByRole('row').first()
+
+ await expect(row).toBeVisible()
+ await row.hover()
+
+ const save = page.getByRole('button', {
+ name: label(messages.healthRegistrationSave),
+ })
+
+ await expect(save).toBeVisible()
+ await save.click()
+
+ const agreeButton = page.getByRole('button', {
+ name: label(messages.healthRegisterModalAccept),
+ })
+
+ await expect(agreeButton).toBeVisible()
+ await agreeButton.click()
+
+ const newHealthCenter = page.getByRole('heading', {
+ name: 'Ný heilsugæsla skráð',
+ })
+
+ await expect(newHealthCenter).toBeVisible()
+ })
+ })
+})
diff --git a/apps/system-e2e/src/tests/islandis/service-portal/acceptance/mocks/assets.mock.ts b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/mocks/assets.mock.ts
new file mode 100644
index 000000000000..e5d1f8dc2cd6
--- /dev/null
+++ b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/mocks/assets.mock.ts
@@ -0,0 +1,94 @@
+import { addXroadMock } from '../../../../../support/wire-mocks'
+import { Response } from '@anev/ts-mountebank'
+import { Properties } from '../../../../../../../../infra/src/dsl/xroad'
+
+export const loadAssetsXroadMocks = async () => {
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: Properties,
+ prefix: 'XROAD_PROPERTIES_SERVICE_V2_PATH',
+ apiPath: '/api/v1/fasteignir',
+ response: [
+ new Response().withJSONBody({
+ paging: {
+ page: 1,
+ pageSize: 10,
+ total: 10,
+ totalPages: 1,
+ offset: 0,
+ hasPreviousPage: false,
+ hasNextPage: false,
+ },
+ fasteignir: [
+ {
+ fasteignanumer: 'F12345',
+ sjalfgefidStadfang: {
+ birtingStutt: 'Eldfjallagata 23',
+ birting: 'Eldfjallagata 23, Siglufjörður',
+ landeignarnumer: '123',
+ sveitarfelagBirting: 'Siglufjörður',
+ postnumer: '580',
+ stadfanganumer: '88',
+ },
+ },
+ ],
+ }),
+ ],
+ })
+
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: Properties,
+ prefix: 'XROAD_PROPERTIES_SERVICE_V2_PATH',
+ apiPath: '/api/v1/fasteignir/12345',
+ response: [
+ new Response().withJSONBody({
+ fasteignanumer: 'F12345',
+ sjalfgefidStadfang: {
+ stadfanganumer: 1234,
+ landeignarnumer: 567,
+ postnumer: 113,
+ sveitarfelagBirting: 'Reykjavík',
+ birting: 'Reykjavík',
+ birtingStutt: 'RVK',
+ },
+ fasteignamat: {
+ gildandiFasteignamat: 50000000,
+ fyrirhugadFasteignamat: 55000000,
+ gildandiMannvirkjamat: 30000000,
+ fyrirhugadMannvirkjamat: 35000000,
+ gildandiLodarhlutamat: 20000000,
+ fyrirhugadLodarhlutamat: 25000000,
+ gildandiAr: 2024,
+ fyrirhugadAr: 2025,
+ },
+ landeign: {
+ landeignarnumer: '123456',
+ lodamat: 75000000,
+ notkunBirting: 'Íbúðarhúsalóð',
+ flatarmal: '300000',
+ flatarmalEining: 'm²',
+ },
+ thinglystirEigendur: {
+ thinglystirEigendur: [
+ {
+ nafn: 'Jón Jónsson',
+ kennitala: '2222222222',
+ eignarhlutfall: 0.5,
+ kaupdagur: new Date(),
+ heimildBirting: 'A+',
+ },
+ {
+ nafn: 'Jóna Jónasdóttir',
+ kennitala: '3333333333',
+ eignarhlutfall: 0.5,
+ kaupdagur: new Date(),
+ heimildBirting: 'A+',
+ },
+ ],
+ },
+ notkunareiningar: undefined,
+ }),
+ ],
+ })
+}
diff --git a/apps/system-e2e/src/tests/islandis/service-portal/acceptance/mocks/healthInsurance.mock.ts b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/mocks/healthInsurance.mock.ts
new file mode 100644
index 000000000000..adce783944ec
--- /dev/null
+++ b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/mocks/healthInsurance.mock.ts
@@ -0,0 +1,207 @@
+import { addXroadMock } from '../../../../../support/wire-mocks'
+import { HttpMethod, Response } from '@anev/ts-mountebank'
+import { HealthInsurance } from '../../../../../../../../infra/src/dsl/xroad'
+
+export const loadHealthInsuranceXroadMocks = async () => {
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ apiPath: '/v1/dentists/current',
+ response: [
+ new Response().withJSONBody({
+ id: 123,
+ name: 'Ósvikinn læknir',
+ }),
+ ],
+ })
+
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ apiPath: '/v1/dentists/status',
+ response: [
+ new Response().withJSONBody({
+ isInsured: true,
+ canRegister: true,
+ contractType: '0',
+ }),
+ ],
+ })
+
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ method: HttpMethod.POST,
+ apiPath: '/v1/dentists/1010101/register',
+ response: new Response().withStatusCode(200).withBody(null),
+ })
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ apiPath: '/v1/dentists/bills',
+ response: [
+ new Response().withJSONBody([
+ {
+ number: 123,
+ amount: 456,
+ coveredAmount: 789,
+ date: new Date('2023-11-29T00:00:00.000Z'),
+ refundDate: new Date('2023-11-29T00:00:00.000Z'),
+ },
+ {
+ number: 10000,
+ amount: 7979,
+ coveredAmount: 6868,
+ date: new Date('2023-04-18T00:00:00.000Z'),
+ refundDate: new Date('2023-05-18T00:00:00.000Z'),
+ },
+ ]),
+ ],
+ })
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ apiPath: '/v1/dentists',
+ response: [
+ new Response().withJSONBody({
+ dentists: [
+ {
+ id: 123,
+ name: 'Ósvikinn læknir',
+ practices: [
+ {
+ practice: 'Alvöru heilsgæsla ehf',
+ address: 'Ekki feikgata 18',
+ region: 'Langtíburtistan',
+ postalCode: '999',
+ },
+ ],
+ },
+ {
+ id: 1010101,
+ name: 'Skottulæknir',
+ practices: [
+ {
+ practice: 'Inn í hól 2',
+ address: 'Uppáhæð',
+ region: 'Kópasker',
+ postalCode: '670',
+ },
+ ],
+ },
+ ],
+ pageInfo: {
+ hasPreviousPage: false,
+ hasNextPage: false,
+ },
+ totalCount: 2,
+ }),
+ ],
+ })
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ apiPath: '/v1/healthcenters',
+ response: [
+ new Response().withJSONBody({
+ healthCenters: [
+ {
+ id: 123,
+ url: 'www.alvoru-docs.au',
+ name: 'Ekki gildra ehf',
+ address: 'Annar álfasteinn til vinstri',
+ city: 'Rauðhólar 25',
+ region: 'Álfaskeið',
+ waitlistRegistration: false,
+ dateFrom: new Date('2023-11-29T00:00:00.000Z'),
+ postalCode: '999',
+ },
+ {
+ id: 987,
+ url: 'doc-ock.oc',
+ name: 'Octavius enterprises',
+ address: 'High street',
+ city: 'New yahk citeh',
+ region: 'Hrunamannahreppur',
+ waitlistRegistration: false,
+ dateFrom: new Date('2023-11-29T00:00:00.000Z'),
+ postalCode: '129',
+ },
+ ],
+ pageInfo: {
+ hasPreviousPage: false,
+ hasNextPage: false,
+ },
+ totalCount: 2,
+ }),
+ ],
+ })
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ apiPath: '/v1/healthcenters/history',
+ response: [
+ new Response().withJSONBody([
+ {
+ dateFrom: new Date('2023-11-29T00:00:00.000Z'),
+ dateTo: new Date('2023-11-30T00:00:00.000Z'),
+ registrationType: 'kvef',
+ registrationTypeCode: 1,
+ healthCenter: {
+ healthCenter: 'Ekki gildra ehf',
+ url: 'www.alvoru-docs.au',
+ doctor: 'Hr. Doktor',
+ },
+ },
+ {
+ dateFrom: new Date('2023-11-05T00:00:00.000Z'),
+ dateTo: new Date('2023-11-05T00:00:00.000Z'),
+ registrationType: 'svo kvefaður omg',
+ registrationTypeCode: 2,
+ healthCenter: {
+ healthCenter: 'Ekki gildra ehf',
+ url: 'www.alvoru-docs.au',
+ doctor: 'Doktor læknir md.',
+ },
+ },
+ ]),
+ ],
+ })
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ apiPath: '/v1/healthcenters/current',
+ response: [
+ new Response().withJSONBody({
+ healthCenter: 'Dýraspítalinn Víðidal',
+ url: 'heal-ur-pets.org',
+ doctor: 'Sámur Læknir, md., góður strákur',
+ canRegister: true,
+ }),
+ ],
+ })
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ method: HttpMethod.POST,
+ apiPath: '/v1/healthcenters/123/register',
+ response: new Response().withStatusCode(200).withBody(null),
+ })
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: HealthInsurance,
+ prefix: 'XROAD_HEALTH_INSURANCE_MY_PAGES_PATH',
+ method: HttpMethod.POST,
+ apiPath: '/v1/healthcenters/987/register',
+ response: new Response().withStatusCode(200).withBody(null),
+ })
+}
diff --git a/apps/system-e2e/src/tests/islandis/service-portal/acceptance/mocks/socialInsurance.mock.ts b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/mocks/socialInsurance.mock.ts
new file mode 100644
index 000000000000..f5ab4fb2b3d7
--- /dev/null
+++ b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/mocks/socialInsurance.mock.ts
@@ -0,0 +1,237 @@
+import { addXroadMock } from '../../../../../support/wire-mocks'
+import { Response } from '@anev/ts-mountebank'
+import { SocialInsuranceAdministration } from '../../../../../../../../infra/src/dsl/xroad'
+
+export const loadSocialInsuranceXroadMocks = async () => {
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: SocialInsuranceAdministration,
+ prefix: 'XROAD_TR_PATH',
+ apiPath: '/api/protected/v1/PaymentPlan?year=2023',
+ response: [
+ new Response().withJSONBody({
+ totalPayment: 61461,
+ subtracted: -15,
+ paidOut: 1234,
+ groups: [
+ {
+ group: 'Skattskyldar greiðslutegundir',
+ groupId: 10,
+ total: 84,
+ monthTotals: [
+ {
+ month: 0,
+ amount: 95630,
+ },
+ ],
+ rows: [
+ {
+ name: '2023 - Ellilífeyrir',
+ total: 96743826,
+ months: [
+ {
+ month: 0,
+ amount: 2346,
+ },
+ ],
+ },
+ {
+ name: '2023 - Orlofs- og desemberuppbót á ellilífeyri',
+ total: 1235464,
+ months: [
+ {
+ month: 0,
+ amount: 15,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ group: 'Frádráttur',
+ groupId: 20,
+ total: -1350545,
+ monthTotals: [
+ {
+ month: 0,
+ amount: -11115,
+ },
+ ],
+ rows: [
+ {
+ name: 'Staðgreiðsla',
+ total: -1555,
+ months: [
+ {
+ month: 0,
+ amount: -9875,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ group: 'Ráðstöfun',
+ groupId: 30,
+ total: 775,
+ monthTotals: [
+ {
+ month: 0,
+ amount: 25252,
+ },
+ ],
+ rows: [
+ {
+ name: 'Til greiðslu',
+ total: 333555,
+ months: [
+ {
+ month: 0,
+ amount: 999888,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }),
+ ],
+ })
+
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: SocialInsuranceAdministration,
+ prefix: 'XROAD_TR_PATH',
+ apiPath: '/api/protected/v1/PaymentPlan?year=2024',
+ response: [
+ new Response().withJSONBody({
+ totalPayment: 1017,
+ subtracted: -567,
+ paidOut: -5,
+ groups: [
+ {
+ group: 'Skattskyldar greiðslutegundir nema arið 2024',
+ groupId: 10,
+ total: 1017,
+ monthTotals: [
+ {
+ month: 0,
+ amount: 1,
+ },
+ {
+ month: 2,
+ amount: 10,
+ },
+ {
+ month: 4,
+ amount: 101,
+ },
+ {
+ month: 6,
+ amount: 905,
+ },
+ ],
+ rows: [
+ {
+ name: '2024 - Ellilífeyrir',
+ total: 9,
+ months: [
+ {
+ month: 0,
+ amount: 9,
+ },
+ ],
+ },
+ {
+ name: '2024 - total test',
+ total: 1,
+ months: [
+ {
+ month: 0,
+ amount: 1,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ group: 'Frádráttur',
+ groupId: 20,
+ total: -567,
+ monthTotals: [
+ {
+ month: 0,
+ amount: -567,
+ },
+ ],
+ rows: [
+ {
+ name: 'Staðgreiðsla',
+ group: 'Frádráttur',
+ groupId: 20,
+ type: 'LXX',
+ overviewType: 'A',
+ period: null,
+ expenseItem: null,
+ from: null,
+ order: 'branaerwa',
+ subType: null,
+ settlementYear: null,
+ months: [
+ {
+ month: 0,
+ amount: -567,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ group: 'Ráðstöfun',
+ groupId: 30,
+ total: -5,
+ monthTotals: [
+ {
+ month: 0,
+ amount: -5,
+ },
+ ],
+ rows: [
+ {
+ name: 'Til greiðslu',
+ total: -5,
+ months: [
+ {
+ month: 0,
+ amount: -5,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }),
+ ],
+ })
+
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: SocialInsuranceAdministration,
+ prefix: 'XROAD_TR_PATH',
+ apiPath: '/api/protected/v1/PaymentPlan/legitimatepayments',
+ response: [
+ new Response().withJSONBody({
+ nextPayment: 1587,
+ previousPayment: 98671498,
+ }),
+ ],
+ })
+
+ await addXroadMock({
+ prefixType: 'only-base-path',
+ config: SocialInsuranceAdministration,
+ prefix: 'XROAD_TR_PATH',
+ apiPath: '/api/protected/v1/PaymentPlan/validyears',
+ response: [new Response().withJSONBody([2024, 2023])],
+ })
+}
diff --git a/apps/system-e2e/src/tests/islandis/service-portal/acceptance/setup-xroad.mocks.ts b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/setup-xroad.mocks.ts
index 5e54453c5148..c3cd1880de88 100644
--- a/apps/system-e2e/src/tests/islandis/service-portal/acceptance/setup-xroad.mocks.ts
+++ b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/setup-xroad.mocks.ts
@@ -1,104 +1,19 @@
-import {
- addXroadMock,
- resetMocks,
- wildcard,
-} from '../../../../support/wire-mocks'
-import { Response } from '@anev/ts-mountebank'
-import { Properties, Base } from '../../../../../../../infra/src/dsl/xroad'
+import { resetMocks, wildcard } from '../../../../support/wire-mocks'
+import { Base } from '../../../../../../../infra/src/dsl/xroad'
import { env } from '../../../../support/urls'
import { getEnvVariables } from '../../../../../../../infra/src/dsl/service-to-environment/pre-process-service'
import { EnvironmentConfig } from '../../../../../../../infra/src/dsl/types/charts'
+import { loadAssetsXroadMocks } from './mocks/assets.mock'
+import { loadHealthInsuranceXroadMocks } from './mocks/healthInsurance.mock'
+import { loadSocialInsuranceXroadMocks } from './mocks/socialInsurance.mock'
-export async function setupXroadMocks() {
+export const setupXroadMocks = async () => {
await resetMocks()
- await addXroadMock({
- prefixType: 'only-base-path',
- config: Properties,
- prefix: 'XROAD_PROPERTIES_SERVICE_V2_PATH',
- apiPath: '/api/v1/fasteignir',
- response: [
- new Response().withJSONBody({
- paging: {
- page: 1,
- pageSize: 10,
- total: 10,
- totalPages: 1,
- offset: 0,
- hasPreviousPage: false,
- hasNextPage: false,
- },
- fasteignir: [
- {
- fasteignanumer: 'F12345',
- sjalfgefidStadfang: {
- birtingStutt: 'Eldfjallagata 23',
- birting: 'Eldfjallagata 23, Siglufjörður',
- landeignarnumer: '123',
- sveitarfelagBirting: 'Siglufjörður',
- postnumer: '580',
- stadfanganumer: '88',
- },
- },
- ],
- }),
- ],
- })
- await addXroadMock({
- prefixType: 'only-base-path',
- config: Properties,
- prefix: 'XROAD_PROPERTIES_SERVICE_V2_PATH',
- apiPath: '/api/v1/fasteignir/12345',
- response: [
- new Response().withJSONBody({
- fasteignanumer: 'F12345',
- sjalfgefidStadfang: {
- stadfanganumer: 1234,
- landeignarnumer: 567,
- postnumer: 113,
- sveitarfelagBirting: 'Reykjavík',
- birting: 'Reykjavík',
- birtingStutt: 'RVK',
- },
- fasteignamat: {
- gildandiFasteignamat: 50000000,
- fyrirhugadFasteignamat: 55000000,
- gildandiMannvirkjamat: 30000000,
- fyrirhugadMannvirkjamat: 35000000,
- gildandiLodarhlutamat: 20000000,
- fyrirhugadLodarhlutamat: 25000000,
- gildandiAr: 2024,
- fyrirhugadAr: 2025,
- },
- landeign: {
- landeignarnumer: '123456',
- lodamat: 75000000,
- notkunBirting: 'Íbúðarhúsalóð',
- flatarmal: '300000',
- flatarmalEining: 'm²',
- },
- thinglystirEigendur: {
- thinglystirEigendur: [
- {
- nafn: 'Jón Jónsson',
- kennitala: '2222222222',
- eignarhlutfall: 0.5,
- kaupdagur: new Date(),
- heimildBirting: 'A+',
- },
- {
- nafn: 'Jóna Jónasdóttir',
- kennitala: '3333333333',
- eignarhlutfall: 0.5,
- kaupdagur: new Date(),
- heimildBirting: 'A+',
- },
- ],
- },
- notkunareiningar: undefined,
- }),
- ],
- })
+ /* Xroad mocks */
+ await loadAssetsXroadMocks()
+ await loadHealthInsuranceXroadMocks()
+ await loadSocialInsuranceXroadMocks()
const { envs } = getEnvVariables(Base.getEnv(), 'system-e2e', env)
const xroadBasePath = envs['XROAD_BASE_PATH']
diff --git a/apps/system-e2e/src/tests/islandis/service-portal/acceptance/social-insurance.spec.ts b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/social-insurance.spec.ts
new file mode 100644
index 000000000000..15faa2e97bf3
--- /dev/null
+++ b/apps/system-e2e/src/tests/islandis/service-portal/acceptance/social-insurance.spec.ts
@@ -0,0 +1,55 @@
+import { test, BrowserContext, expect } from '@playwright/test'
+import { icelandicAndNoPopupUrl, urls } from '../../../../support/urls'
+import { session } from '../../../../support/session'
+import { disableI18n } from '../../../../support/disablers'
+
+const homeUrl = `${urls.islandisBaseUrl}/minarsidur`
+test.use({ baseURL: urls.islandisBaseUrl })
+
+test.describe('MS - Social Insurance', () => {
+ let context: BrowserContext
+
+ test.beforeAll(async ({ browser }) => {
+ context = await session({
+ browser: browser,
+ storageState: 'service-portal-faereyjar.json',
+ homeUrl,
+ phoneNumber: '0102399',
+ idsLoginOn: true,
+ })
+ })
+
+ test.afterAll(async () => {
+ await context.close()
+ })
+
+ test('payment plan', async () => {
+ const page = await context.newPage()
+ await disableI18n(page)
+
+ await test.step('should display data when switching years', async () => {
+ // Arrange
+ await page.goto(
+ icelandicAndNoPopupUrl('minarsidur/framfaersla/greidsluaetlun'),
+ )
+
+ const title = page.getByRole('heading', {
+ name: 'Framfærsla',
+ })
+ await expect(title).toBeVisible()
+
+ await expect(
+ page.getByText('Skattskyldar greiðslutegundir nema arið 2024'),
+ ).toBeVisible()
+
+ const select = page.getByTestId('select-payment-plan-date-picker')
+ await select.click()
+ await page.keyboard.press('ArrowDown')
+ await page.keyboard.press('Enter')
+
+ await expect(
+ page.getByText('Skattskyldar greiðslutegundir'),
+ ).toBeVisible()
+ })
+ })
+})
diff --git a/apps/web/components/ActionCategoryCard/ActionCategoryCard.tsx b/apps/web/components/ActionCategoryCard/ActionCategoryCard.tsx
index 666b9ec8b5b2..4b654dad61a7 100644
--- a/apps/web/components/ActionCategoryCard/ActionCategoryCard.tsx
+++ b/apps/web/components/ActionCategoryCard/ActionCategoryCard.tsx
@@ -141,22 +141,23 @@ const Component = forwardRef(
display="flex"
justifyContent={['flexStart', 'flexEnd']}
flexDirection="row"
+ style={{ cursor: cta.disabled ? 'not-allowed' : undefined }}
>
{cta.href ? (
-
-
+
+
{cta.label}
-
-
+
+
) : (
{
- /* eslint-disable */
- const w: any = window
- w.__lc = w.__lc || {}
- w.__lc.license = license
+import { Query, QueryGetNamespaceArgs } from '@island.is/web/graphql/schema'
+import { useNamespace } from '@island.is/web/hooks'
+import { useI18n } from '@island.is/web/i18n'
+import { GET_NAMESPACE_QUERY } from '@island.is/web/screens/queries'
- if (typeof group === 'number') {
- w.__lc.group = group
- }
+import { ChatBubble } from '../ChatBubble'
+import { LiveChatIncChatPanelProps } from '../types'
- const widget = (function (n: any, t, c) {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore make web strict
- function i(n) {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore make web strict
- return e._h ? e._h.apply(null, n) : e._q.push(n)
- }
- var e = {
- _q: [],
- _h: null,
- _v: version,
- on: function () {
- i(['on', c.call(arguments)])
- },
- once: function () {
- i(['once', c.call(arguments)])
- },
- off: function () {
- i(['off', c.call(arguments)])
- },
- get: function () {
- if (!e._h)
- throw new Error("[LiveChatWidget] You can't use getters before load.")
- return i(['get', c.call(arguments)])
- },
- call: function () {
- i(['call', c.call(arguments)])
- },
- init: function () {
- var n = t.createElement('script')
- ;(n.async = !0),
- (n.type = 'text/javascript'),
- (n.src = SCRIPT_SRC),
- t.head.appendChild(n)
- },
- }
- !n.__lc.asyncInit && e.init(), (n.LiveChatWidget = n.LiveChatWidget || e)
- return e
- })(window, document, [].slice)
- return widget
- /* eslint-enable */
-}
+declare const window: ExtendedWindow
export const LiveChatIncChatPanel = ({
license,
- version,
group,
+ showLauncher,
+ pushUp,
}: LiveChatIncChatPanelProps) => {
+ const [loading, setLoading] = useState(false)
+ const [hasButtonBeenClicked, setHasButtonBeenClicked] = useState(false)
+ const { activeLocale } = useI18n()
+ const { data } = useQuery(GET_NAMESPACE_QUERY, {
+ variables: {
+ input: {
+ lang: activeLocale,
+ namespace: 'ChatPanels',
+ },
+ },
+ })
+
+ const namespace = useMemo(
+ () => JSON.parse(data?.getNamespace?.fields || '{}'),
+ [data?.getNamespace?.fields],
+ )
+
+ const n = useNamespace(namespace)
+
useEffect(() => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const widget: any = activateWidget(license, version, group)
- return () => widget?.call('destroy')
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
- return null
+ if (!hasButtonBeenClicked && !showLauncher) {
+ return () => {
+ // No need for cleanup if we don't initialize widget
+ }
+ }
+
+ const widget = createWidget({
+ license,
+ group,
+ })
+
+ widget.init()
+
+ window.LiveChatWidget.on('ready', () => {
+ setLoading(false)
+ })
+
+ window.LiveChatWidget.call('maximize')
+
+ return () => {
+ widget.destroy()
+ }
+ }, [group, hasButtonBeenClicked, license, showLauncher])
+
+ if (showLauncher) {
+ return null
+ }
+
+ return (
+ {
+ if (!hasButtonBeenClicked) {
+ setLoading(true)
+ setHasButtonBeenClicked(true)
+ } else if (!loading) {
+ window.LiveChatWidget.call('maximize')
+ }
+ }}
+ pushUp={pushUp}
+ loading={loading}
+ />
+ )
}
export default LiveChatIncChatPanel
diff --git a/apps/web/components/ChatPanel/types.ts b/apps/web/components/ChatPanel/types.ts
index 817de6d7c596..4f3ce121546c 100644
--- a/apps/web/components/ChatPanel/types.ts
+++ b/apps/web/components/ChatPanel/types.ts
@@ -6,9 +6,12 @@ export interface BoostChatPanelProps {
}
export interface LiveChatIncChatPanelProps {
- license: number
+ license: string
version: string
- group?: number
+ group?: string
+ // Whether the default LiveChatInc launcher is shown
+ showLauncher?: boolean
+ pushUp?: boolean
}
export interface WatsonChatPanelProps {
@@ -26,7 +29,7 @@ export interface WatsonChatPanelProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onLoad?: (instance: any) => void
- // Whether the default launcher is shown
+ // Whether the default IBM Watson launcher is shown
showLauncher?: boolean
// If don't use the default launcher that IBM Watson provides, should the chat bubble launcher be pushed up?
diff --git a/apps/web/components/Form/Form.tsx b/apps/web/components/Form/Form.tsx
index abe31adf624e..b7c39dfd56b7 100644
--- a/apps/web/components/Form/Form.tsx
+++ b/apps/web/components/Form/Form.tsx
@@ -1,19 +1,21 @@
-import { useState } from 'react'
-import slugify from '@sindresorhus/slugify'
-import { useMutation } from '@apollo/client/react'
+import { useMemo, useState } from 'react'
import gql from 'graphql-tag'
+import { useMutation, useQuery } from '@apollo/client/react'
+import slugify from '@sindresorhus/slugify'
+
import {
Box,
- Text,
+ Button,
+ Checkbox,
Input,
- Select,
+ InputFileUpload,
RadioButton,
+ Select,
Stack,
- Checkbox,
- Button,
- InputFileUpload,
+ Text,
UploadFile,
} from '@island.is/island-ui/core'
+import { fileExtensionWhitelist } from '@island.is/island-ui/core/types'
import {
Form as FormType,
GenericFormMutation,
@@ -21,12 +23,16 @@ import {
Mutation,
MutationCreateUploadUrlArgs,
PresignedPost,
+ Query,
+ QueryGetNamespaceArgs,
} from '@island.is/web/graphql/schema'
-import { GENERIC_FORM_MUTATION } from '@island.is/web/screens/queries/Form'
import { useNamespace } from '@island.is/web/hooks'
+import { useI18n } from '@island.is/web/i18n'
+import { GET_NAMESPACE_QUERY } from '@island.is/web/screens/queries'
+import { GENERIC_FORM_MUTATION } from '@island.is/web/screens/queries/Form'
import { isValidEmail } from '@island.is/web/utils/isValidEmail'
import { isValidNationalId } from '@island.is/web/utils/isValidNationalId'
-import { fileExtensionWhitelist } from '@island.is/island-ui/core/types'
+
import * as styles from './Form.css'
const CREATE_UPLOAD_URL = gql`
@@ -61,7 +67,6 @@ interface FormFieldProps {
interface FormProps {
form: FormType
- namespace: Record
}
export const FormField = ({
@@ -222,8 +227,28 @@ type ErrorData = {
error?: string
}
-export const Form = ({ form, namespace }: FormProps) => {
+export const Form = ({ form }: FormProps) => {
+ const { activeLocale } = useI18n()
+
+ const { data: namespaceResponse } = useQuery(
+ GET_NAMESPACE_QUERY,
+ {
+ variables: {
+ input: {
+ lang: activeLocale,
+ namespace: 'Forms',
+ },
+ },
+ },
+ )
+
+ const namespace = useMemo(
+ () => JSON.parse(namespaceResponse?.getNamespace?.fields || '{}'),
+ [namespaceResponse?.getNamespace?.fields],
+ )
+
const n = useNamespace(namespace)
+
const defaultNamespace = form.defaultFieldNamespace
const defaultNameFieldIsShown = defaultNamespace?.displayNameField ?? true
@@ -268,6 +293,41 @@ export const Form = ({ form, namespace }: FormProps) => {
setData({ ...data, [field]: String(value) })
}
+ const requiredFieldText = n(
+ 'requiredField',
+ activeLocale === 'is'
+ ? 'Þennan reit þarf að fylla út.'
+ : 'This field needs to be filled out.',
+ )
+
+ const requiredFileText = n(
+ 'requiredFile',
+ activeLocale === 'is'
+ ? 'Þennan reit þarf að fylla út.'
+ : 'This field needs to be filled out.',
+ )
+
+ const requiredCheckboxText = n(
+ 'requiredCheckbox',
+ activeLocale === 'is'
+ ? 'Þennan reit þarf að fylla út.'
+ : 'This field needs to be filled out.',
+ )
+
+ const invalidEmailText = n(
+ 'formInvalidEmail',
+ activeLocale === 'is'
+ ? 'Þetta er ekki gilt netfang.'
+ : 'This is not a valid email.',
+ )
+
+ const invalidNationalIdText = n(
+ 'formInvalidNationalId',
+ activeLocale === 'is'
+ ? 'Þetta er ekki gild kennitala.'
+ : 'This is not a valid national id.',
+ )
+
const validate = () => {
const err = Object.keys(data)
.map((slug) => {
@@ -278,14 +338,14 @@ export const Form = ({ form, namespace }: FormProps) => {
if (slug === 'name' && !data['name']) {
return {
field: slug,
- error: n('formInvalidName', 'Þennan reit þarf að fylla út.'),
+ error: requiredFieldText,
}
}
if (slug === 'email' && !isValidEmail.test(data['email'])) {
return {
field: slug,
- error: n('formInvalidEmail', 'Þetta er ekki gilt netfang.'),
+ error: invalidEmailText,
}
}
@@ -302,7 +362,7 @@ export const Form = ({ form, namespace }: FormProps) => {
) {
return {
field: slug,
- error: n('formInvalidEmail', 'Þetta er ekki gilt netfang.'),
+ error: invalidEmailText,
}
}
@@ -312,7 +372,7 @@ export const Form = ({ form, namespace }: FormProps) => {
) {
return {
field: slug,
- error: n('formInvalidNationalId', 'Þetta er ekki gild kennitala.'),
+ error: invalidNationalIdText,
}
}
@@ -324,7 +384,7 @@ export const Form = ({ form, namespace }: FormProps) => {
) {
return {
field: slug,
- error: n('formInvalidName', 'Þennan reit þarf að fylla út.'),
+ error: requiredFieldText,
}
}
@@ -336,7 +396,7 @@ export const Form = ({ form, namespace }: FormProps) => {
) {
return {
field: slug,
- error: n('formInvalidName', 'Þennan reit þarf að fylla út.'),
+ error: requiredCheckboxText,
}
}
@@ -347,7 +407,7 @@ export const Form = ({ form, namespace }: FormProps) => {
) {
return {
field: slug,
- error: n('formInvalidName', 'Þennan reit þarf að fylla út.'),
+ error: requiredFileText,
}
}
@@ -574,7 +634,10 @@ export const Form = ({ form, namespace }: FormProps) => {
{success && (
<>
- {n('formSuccessTitle', 'Sending tókst!')}
+ {n(
+ 'formSuccessTitle',
+ activeLocale === 'is' ? 'Sending tókst!' : 'Success',
+ )}
{form.successText}
>
@@ -582,12 +645,19 @@ export const Form = ({ form, namespace }: FormProps) => {
{failure && (
<>
- {n('formErrorTitle', 'Úps, eitthvað fór úrskeiðis')}
+ {n(
+ 'formErrorTitle',
+ activeLocale === 'is'
+ ? 'Úps, eitthvað fór úrskeiðis'
+ : 'Something went wrong',
+ )}
{n(
'formEmailUnknownError',
- 'Villa kom upp við sendingu. Reynið aftur síðar.',
+ activeLocale === 'is'
+ ? 'Villa kom upp við sendingu. Reynið aftur síðar.'
+ : 'An error occurred, please try again later.',
)}
>
@@ -606,11 +676,18 @@ export const Form = ({ form, namespace }: FormProps) => {
{
{
key={slug}
header={field.title}
description={field.placeholder}
- buttonLabel={n('formSelectFiles', 'Veldu skrár')}
+ buttonLabel={n(
+ 'formSelectFiles',
+ activeLocale === 'is' ? 'Veldu skrár' : 'Select files',
+ )}
accept={Object.values(fileExtensionWhitelist)}
fileList={fileList[slug]}
errorMessage={
@@ -707,7 +791,7 @@ export const Form = ({ form, namespace }: FormProps) => {
})}
onSubmit()} loading={isSubmitting}>
- {n('formSend', 'Senda')}
+ {n('formSend', activeLocale === 'is' ? 'Senda' : 'Submit')}
diff --git a/apps/web/components/ListViewCard/ListViewCard.tsx b/apps/web/components/ListViewCard/ListViewCard.tsx
index da2bf43b64a1..7ae66c6905cb 100644
--- a/apps/web/components/ListViewCard/ListViewCard.tsx
+++ b/apps/web/components/ListViewCard/ListViewCard.tsx
@@ -125,21 +125,27 @@ export const ListViewCard = ({
)}
-
+
{cta.href ? (
-
-
+
+
{cta.label}
-
-
+
+
) : (
{
+ const { formatMessage } = useIntl()
+
+ return (
+
+ {(institution || department || publicationDate) && (
+
+ {institution && (
+
+
+ {institution}
+
+
+ )}
+ {(department || publicationDate) && (
+
+
+ {department}
+ {department && publicationDate && ' - '}
+ {publicationDate && `Útg: ${formatDate(publicationDate)}`}
+
+
+ )}
+
+ )}
+ {publicationNumber && (
+
+ {publicationNumber}
+
+ )}
+ {title && (
+
+ {title}
+
+ )}
+
+ {categories && categories.length && (
+
+ {categories.map((cat) => {
+ return (
+
+ {cat}
+
+ )
+ })}
+
+ )}
+ {link && (
+
+
+ {formatMessage(m.general.seeMore)}
+ {' '}
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/web/components/OfficialJournalOfIceland/OJOIAdvertDisplay.css.ts b/apps/web/components/OfficialJournalOfIceland/OJOIAdvertDisplay.css.ts
new file mode 100644
index 000000000000..e4c583d6232e
--- /dev/null
+++ b/apps/web/components/OfficialJournalOfIceland/OJOIAdvertDisplay.css.ts
@@ -0,0 +1,6 @@
+import { style } from '@vanilla-extract/css'
+
+import { regulationContentStyling } from '@island.is/regulations/styling'
+
+export const bodyText = style({})
+regulationContentStyling(bodyText)
diff --git a/apps/web/components/OfficialJournalOfIceland/OJOIAdvertDisplay.tsx b/apps/web/components/OfficialJournalOfIceland/OJOIAdvertDisplay.tsx
new file mode 100644
index 000000000000..58b5c1fe983c
--- /dev/null
+++ b/apps/web/components/OfficialJournalOfIceland/OJOIAdvertDisplay.tsx
@@ -0,0 +1,55 @@
+import { Box, Text } from '@island.is/island-ui/core'
+
+import * as s from './OJOIAdvertDisplay.css'
+
+export type OJOIAdvertDisplayProps = {
+ advertNumber: string
+ signatureDate: string
+ advertType: string
+ advertSubject: string
+ advertText: string
+ isLegacy: boolean
+}
+
+export const OJOIAdvertDisplay = ({
+ advertNumber,
+ signatureDate,
+ advertType,
+ advertSubject,
+ advertText,
+ isLegacy,
+}: OJOIAdvertDisplayProps) => {
+ if (!advertText) {
+ return null
+ }
+
+ return (
+
+
+
+ Nr. {advertNumber}
+
+
+ Undirritað: {signatureDate}
+
+
+
+ {advertType}
+ {advertSubject}
+
+
+
+ )
+}
diff --git a/apps/web/components/OfficialJournalOfIceland/OJOIHomeIntro.css.ts b/apps/web/components/OfficialJournalOfIceland/OJOIHomeIntro.css.ts
new file mode 100644
index 000000000000..9b25a4d8ab54
--- /dev/null
+++ b/apps/web/components/OfficialJournalOfIceland/OJOIHomeIntro.css.ts
@@ -0,0 +1,31 @@
+import { style } from '@vanilla-extract/css'
+
+import { spacing } from '@island.is/island-ui/theme'
+
+export const introSummary = style({})
+
+export const introBody = style({
+ paddingTop: spacing[2],
+ paddingBottom: spacing[2],
+ maxHeight: '50em',
+ overflow: 'hidden',
+ transition: 'all 500ms ease-in, padding 100ms',
+
+ selectors: {
+ '[hidden]&': {
+ padding: 0,
+ maxHeight: 0,
+ display: 'block',
+ visibility: 'hidden',
+ transition: 'all 400ms ease-in, padding 100ms 400ms',
+ },
+ },
+})
+
+export const introImage = style({
+ marginBottom: -spacing[2],
+ maxHeight: '17em',
+ display: 'block',
+ margin: 'auto',
+ width: '100%',
+})
diff --git a/apps/web/components/OfficialJournalOfIceland/OJOIHomeIntro.tsx b/apps/web/components/OfficialJournalOfIceland/OJOIHomeIntro.tsx
new file mode 100644
index 000000000000..d4978dd4cbcb
--- /dev/null
+++ b/apps/web/components/OfficialJournalOfIceland/OJOIHomeIntro.tsx
@@ -0,0 +1,94 @@
+import React, { ReactNode } from 'react'
+
+import {
+ Box,
+ GridColumn,
+ GridContainer,
+ GridRow,
+ Inline,
+ Input,
+ Tag,
+ TagVariant,
+ Text,
+} from '@island.is/island-ui/core'
+import { Organization } from '@island.is/web/graphql/schema'
+
+import * as s from './OJOIHomeIntro.css'
+
+export type OJOIHomeIntroProps = {
+ organization?: Organization
+ breadCrumbs: ReactNode
+ searchPlaceholder: string
+ quickLinks: Array<{ title: string; href: string; variant?: TagVariant }>
+ searchUrl: string
+ shortcutsTitle: string
+ featuredImage?: string
+}
+
+export const OJOIHomeIntro = (props: OJOIHomeIntroProps) => {
+ const organization = props.organization
+
+ if (!organization) {
+ return null
+ }
+
+ return (
+
+
+
+ {props.breadCrumbs}
+
+
+ {organization && organization.title}
+
+
+ {organization?.description && (
+ {organization?.description}
+ )}
+
+
+
+
+
+
+
+ {props.shortcutsTitle}
+
+
+ {props.quickLinks.map((q, i) => (
+
+ {q.title}
+
+ ))}
+
+
+
+
+ {props.featuredImage && (
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/web/components/OfficialJournalOfIceland/OJOISearchGridView.tsx b/apps/web/components/OfficialJournalOfIceland/OJOISearchGridView.tsx
new file mode 100644
index 000000000000..0fa637d3df6d
--- /dev/null
+++ b/apps/web/components/OfficialJournalOfIceland/OJOISearchGridView.tsx
@@ -0,0 +1,34 @@
+import { Locale } from 'locale'
+
+import { Stack } from '@island.is/island-ui/core'
+import { OfficialJournalOfIcelandAdvertsResponse } from '@island.is/web/graphql/schema'
+import { useLinkResolver } from '@island.is/web/hooks'
+
+import { OJOIAdvertCard } from './OJOIAdvertCard'
+
+export const OJOISearchGridView = ({
+ adverts,
+ locale,
+}: {
+ adverts: OfficialJournalOfIcelandAdvertsResponse['adverts']
+ locale: Locale
+}) => {
+ const { linkResolver } = useLinkResolver()
+
+ return (
+
+ {adverts.map((ad) => (
+ cat.title)}
+ link={linkResolver('ojoiadvert', [ad.id], locale).href}
+ />
+ ))}
+
+ )
+}
diff --git a/apps/web/components/OfficialJournalOfIceland/OJOISearchListView.tsx b/apps/web/components/OfficialJournalOfIceland/OJOISearchListView.tsx
new file mode 100644
index 000000000000..745ba0ba57e5
--- /dev/null
+++ b/apps/web/components/OfficialJournalOfIceland/OJOISearchListView.tsx
@@ -0,0 +1,69 @@
+import format from 'date-fns/format'
+import is from 'date-fns/locale/is'
+import { Locale } from 'locale'
+
+import { LinkV2, Table as T, Text } from '@island.is/island-ui/core'
+import { OfficialJournalOfIcelandAdvertsResponse } from '@island.is/web/graphql/schema'
+import { useLinkResolver } from '@island.is/web/hooks'
+
+export const OJOISearchListView = ({
+ adverts,
+ locale,
+}: {
+ adverts: OfficialJournalOfIcelandAdvertsResponse['adverts']
+ locale: Locale
+}) => {
+ const { linkResolver } = useLinkResolver()
+
+ return (
+
+
+
+ Útgáfa
+ Deild
+ Númer
+ Heiti
+ Stofnun
+
+
+
+ {adverts.map((ad) => (
+
+
+
+ {format(new Date(ad.publicationDate), 'dd.MM.yyyy', {
+ locale: is,
+ })}
+
+
+
+
+ {ad.department?.title}
+
+
+
+
+ {ad.publicationNumber?.full}
+
+
+
+
+
+ {ad.title}
+
+
+
+
+ {ad.involvedParty?.title}
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/components/OfficialJournalOfIceland/OJOIUtils.ts b/apps/web/components/OfficialJournalOfIceland/OJOIUtils.ts
new file mode 100644
index 000000000000..d534035f515b
--- /dev/null
+++ b/apps/web/components/OfficialJournalOfIceland/OJOIUtils.ts
@@ -0,0 +1,77 @@
+import format from 'date-fns/format'
+import is from 'date-fns/locale/is'
+
+import { StringOption as Option } from '@island.is/island-ui/core'
+import { sortAlpha } from '@island.is/shared/utils'
+import {
+ OfficialJournalOfIcelandAdvertCategory,
+ OfficialJournalOfIcelandAdvertEntity,
+ OfficialJournalOfIcelandAdvertMainCategory,
+ OfficialJournalOfIcelandAdvertType,
+} from '@island.is/web/graphql/schema'
+
+export const splitArrayIntoGroups = (array: Array, groupSize: number) => {
+ return Array.from({ length: Math.ceil(array.length / groupSize) }, (_, i) =>
+ array.slice(i * groupSize, (i + 1) * groupSize),
+ )
+}
+
+export const removeEmptyFromObject = (obj: Record) => {
+ return Object.entries(obj)
+ .filter(([_, v]) => !!v)
+ .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {})
+}
+
+export const emptyOption = (label?: string): Option => ({
+ label: label ? `– ${label} –` : '—',
+ value: '',
+})
+
+export const findValueOption = (
+ options: ReadonlyArray,
+ value?: string,
+) => {
+ // NOTE: The returned option MUST NOT be a copy (with trimmed value,
+ // even if it would look nicer) because react-select seems to do an
+ // internal `===` comparison against the options list, and thus copies
+ // will fail to appear selected in the dropdown list.
+ return (value && options.find((opt) => opt.value === value)) || null
+}
+
+export type EntityOption = Option & {
+ mainCategory?: string
+ department?: string
+}
+
+export const mapEntityToOptions = (
+ entities?: Array<
+ | OfficialJournalOfIcelandAdvertEntity
+ | OfficialJournalOfIcelandAdvertType
+ | OfficialJournalOfIcelandAdvertCategory
+ | OfficialJournalOfIcelandAdvertMainCategory
+ >,
+): EntityOption[] => {
+ if (!entities) {
+ return []
+ }
+ return entities.map((e) => {
+ return {
+ label: e.title,
+ value: e.slug,
+ }
+ })
+}
+
+export const sortCategories = (cats: EntityOption[]) => {
+ return cats.sort((a, b) => {
+ return sortAlpha('title')(a, b)
+ })
+}
+
+export const formatDate = (date: string, df = 'dd.MM.yyyy') => {
+ try {
+ return format(new Date(date), df, { locale: is })
+ } catch (e) {
+ throw new Error(`Could not format date: ${date}`)
+ }
+}
diff --git a/apps/web/components/OfficialJournalOfIceland/OJOIWrapper.tsx b/apps/web/components/OfficialJournalOfIceland/OJOIWrapper.tsx
new file mode 100644
index 000000000000..4d72479367c4
--- /dev/null
+++ b/apps/web/components/OfficialJournalOfIceland/OJOIWrapper.tsx
@@ -0,0 +1,149 @@
+import React, { ReactNode, useEffect, useState } from 'react'
+import { useWindowSize } from 'react-use'
+import NextLink from 'next/link'
+
+import {
+ Box,
+ BreadCrumbItem,
+ Breadcrumbs,
+ Button,
+ LinkV2,
+ Text,
+} from '@island.is/island-ui/core'
+import { theme } from '@island.is/island-ui/theme'
+import { Footer as WebFooter } from '@island.is/web/components'
+import { Image, Organization } from '@island.is/web/graphql/schema'
+import { usePlausiblePageview } from '@island.is/web/hooks'
+import SidebarLayout from '@island.is/web/screens/Layouts/SidebarLayout'
+
+import { HeadWithSocialSharing } from '../HeadWithSocialSharing/HeadWithSocialSharing'
+
+type WrapperProps = {
+ pageTitle: string
+ pageDescription?: string
+ pageFeaturedImage?: string
+ organization?: Organization
+ breadcrumbItems?: BreadCrumbItem[]
+ children?: ReactNode
+ sidebarContent?: ReactNode
+ goBackUrl?: string
+ hideTitle?: boolean
+}
+
+export const OJOIWrapper = ({
+ pageTitle,
+ pageDescription,
+ pageFeaturedImage,
+ organization,
+ breadcrumbItems,
+ children,
+ sidebarContent,
+ goBackUrl,
+ hideTitle,
+}: WrapperProps) => {
+ const { width } = useWindowSize()
+ const [isMobile, setIsMobile] = useState()
+ usePlausiblePageview(organization?.trackingDomain ?? undefined)
+
+ useEffect(() => {
+ setIsMobile(width < theme.breakpoints.md)
+ }, [width])
+
+ if (!organization) {
+ return null
+ }
+
+ const metaTitleSuffix =
+ pageTitle !== organization.title ? ` | ${organization.title}` : ''
+
+ return (
+ <>
+
+
+ {sidebarContent && (
+
+ {goBackUrl ? (
+
+
+
+ Til baka
+
+
+
+ ) : null}
+
+ {sidebarContent}
+ >
+ }
+ >
+
+ {breadcrumbItems && (
+ {
+ return item?.href ? (
+
+ {link}
+
+ ) : (
+ link
+ )
+ }}
+ />
+ )}
+
+ {!hideTitle && (
+
+ {pageTitle}
+
+ )}
+
+ {pageDescription && (
+
+ {pageDescription}
+
+ )}
+
+ {isMobile && (
+
+ {sidebarContent}
+
+ )}
+
+ {children}
+
+
+ )}
+
+ {!sidebarContent && children}
+
+
+
+
+ >
+ )
+}
diff --git a/apps/web/components/OfficialJournalOfIceland/index.ts b/apps/web/components/OfficialJournalOfIceland/index.ts
new file mode 100644
index 000000000000..a158dddec859
--- /dev/null
+++ b/apps/web/components/OfficialJournalOfIceland/index.ts
@@ -0,0 +1,7 @@
+export { OJOIHomeIntro } from './OJOIHomeIntro'
+export { OJOIWrapper } from './OJOIWrapper'
+export { OJOIAdvertDisplay } from './OJOIAdvertDisplay'
+export { OJOIAdvertCard } from './OJOIAdvertCard'
+export { OJOISearchListView } from './OJOISearchListView'
+export { OJOISearchGridView } from './OJOISearchGridView'
+export * from './OJOIUtils'
diff --git a/apps/web/components/Organization/Slice/FeaturedArticles/FeaturedArticlesSlice.tsx b/apps/web/components/Organization/Slice/FeaturedArticles/FeaturedArticlesSlice.tsx
index 4661521d1400..35192570d7c8 100644
--- a/apps/web/components/Organization/Slice/FeaturedArticles/FeaturedArticlesSlice.tsx
+++ b/apps/web/components/Organization/Slice/FeaturedArticles/FeaturedArticlesSlice.tsx
@@ -1,5 +1,6 @@
import React from 'react'
+import { SliceType } from '@island.is/island-ui/contentful'
import {
Box,
Button,
@@ -14,6 +15,7 @@ import { Article, FeaturedArticles } from '@island.is/web/graphql/schema'
import { useNamespace } from '@island.is/web/hooks'
import { LinkType, useLinkResolver } from '@island.is/web/hooks/useLinkResolver'
import { hasProcessEntries } from '@island.is/web/utils/article'
+import { webRichText } from '@island.is/web/utils/richText'
interface SliceProps {
slice: FeaturedArticles
@@ -52,6 +54,11 @@ export const FeaturedArticlesSlice: React.FC<
{slice.title}
+ {slice.introText && slice.introText.length > 0 && (
+
+ {webRichText((slice.introText ?? []) as SliceType[])}
+
+ )}
{(slice.automaticallyFetchArticles
? sortedArticles
diff --git a/apps/web/components/Organization/Wrapper/OrganizationWrapper.tsx b/apps/web/components/Organization/Wrapper/OrganizationWrapper.tsx
index 58ec40557545..fe35329d43a5 100644
--- a/apps/web/components/Organization/Wrapper/OrganizationWrapper.tsx
+++ b/apps/web/components/Organization/Wrapper/OrganizationWrapper.tsx
@@ -1016,7 +1016,7 @@ export const OrganizationWrapper: React.FC<
}}
/>
- {organizationPage.secondaryMenu && (
+ {organizationPage.secondaryMenu && secondaryNavList.length > 0 && (
{
const { linkResolver } = useLinkResolver()
- const { width } = useWindowSize()
- const isMobileScreenWidth = width < theme.breakpoints.lg
-
return (
-
+
+
+
+
+
)
}
-
-export default RettindagaeslaFatladsFolksHeader
diff --git a/apps/web/components/Organization/Wrapper/Themes/RettindagaeslaFatladsFolksTheme/index.ts b/apps/web/components/Organization/Wrapper/Themes/RettindagaeslaFatladsFolksTheme/index.ts
index 77a9385f9f4d..5df91aa79758 100644
--- a/apps/web/components/Organization/Wrapper/Themes/RettindagaeslaFatladsFolksTheme/index.ts
+++ b/apps/web/components/Organization/Wrapper/Themes/RettindagaeslaFatladsFolksTheme/index.ts
@@ -1,3 +1 @@
-import Header from './RettindagaeslaFatladsFolksHeader'
-
-export const RettindagaeslaFatladsFolksHeader = Header
+export * from './RettindagaeslaFatladsFolksHeader'
diff --git a/apps/web/components/Organization/Wrapper/config.ts b/apps/web/components/Organization/Wrapper/config.ts
index cbf564cf6092..79fa63cc10d4 100644
--- a/apps/web/components/Organization/Wrapper/config.ts
+++ b/apps/web/components/Organization/Wrapper/config.ts
@@ -1,7 +1,5 @@
import { Locale } from 'locale'
-import { setupOneScreenWatsonChatBot } from '@island.is/web/utils/webChat'
-
import {
LiveChatIncChatPanelProps,
WatsonChatPanelProps,
@@ -15,41 +13,55 @@ export const liveChatIncConfig: Record<
// HSN - Organization
// https://app.contentful.com/spaces/8k0h54kbe6bj/entries/EM4Y0gF4OoGhH9ZY0Dxl6
EM4Y0gF4OoGhH9ZY0Dxl6: {
- license: 15092154,
+ license: '15092154',
version: '2.0',
},
// HSU - Organization
// https://app.contentful.com/spaces/8k0h54kbe6bj/entries/1UDhUhE8pzwnl0UxuzRUMk
'1UDhUhE8pzwnl0UxuzRUMk': {
- license: 15092154,
+ license: '15092154',
version: '2.0',
},
// HVE - Organization
// https://app.contentful.com/spaces/8k0h54kbe6bj/entries/Un4jJk0rPybt9fu8gk94m
Un4jJk0rPybt9fu8gk94m: {
- license: 15092154,
+ license: '15092154',
version: '2.0',
},
+
+ // Vinnueftirlitið - Organization
+ '39S5VumPfb1hXBJm3SnE02': {
+ license: '13346703',
+ version: '2.0',
+ showLauncher: false,
+ },
},
en: {
// HSN - Organization
// https://app.contentful.com/spaces/8k0h54kbe6bj/entries/EM4Y0gF4OoGhH9ZY0Dxl6
EM4Y0gF4OoGhH9ZY0Dxl6: {
- license: 15092154,
+ license: '15092154',
version: '2.0',
},
// HSU - Organization
// https://app.contentful.com/spaces/8k0h54kbe6bj/entries/1UDhUhE8pzwnl0UxuzRUMk
'1UDhUhE8pzwnl0UxuzRUMk': {
- license: 15092154,
+ license: '15092154',
version: '2.0',
},
// HVE - Organization
// https://app.contentful.com/spaces/8k0h54kbe6bj/entries/Un4jJk0rPybt9fu8gk94m
Un4jJk0rPybt9fu8gk94m: {
- license: 15092154,
+ license: '15092154',
version: '2.0',
},
+
+ // Vinnueftirlitið - Organization
+ '39S5VumPfb1hXBJm3SnE02': {
+ license: '13346703',
+ version: '2.0',
+ showLauncher: false,
+ },
},
}
diff --git a/apps/web/components/PageLoader/PageLoader.tsx b/apps/web/components/PageLoader/PageLoader.tsx
index 80477a12466b..091a188fce3c 100644
--- a/apps/web/components/PageLoader/PageLoader.tsx
+++ b/apps/web/components/PageLoader/PageLoader.tsx
@@ -1,29 +1,46 @@
import React, { useEffect, useRef } from 'react'
-import { useRouter } from 'next/router'
import { LoadingBarRef } from 'react-top-loading-bar'
+import { useRouter } from 'next/router'
+
import { PageLoader as PageLoaderUI } from '@island.is/island-ui/core'
+type RouteChangeFunction = (url: string, props: { shallow: boolean }) => void
+
export const PageLoader = () => {
const router = useRouter()
const ref = useRef(null)
+ const state = useRef<'idle' | 'loading'>('idle')
useEffect(() => {
- const start = () => {
- ref?.current?.continuousStart()
+ const onStart: RouteChangeFunction = (_, { shallow }) => {
+ if (!shallow) {
+ state.current = 'loading'
+ ref.current?.continuousStart()
+ }
+ }
+ const onComplete: RouteChangeFunction = (_, { shallow }) => {
+ if (!shallow) {
+ state.current = 'idle'
+ ref.current?.complete()
+ }
}
- const done = () => {
- ref.current?.complete()
+ const onError = () => {
+ if (state.current === 'loading') {
+ ref.current?.complete()
+ state.current = 'idle'
+ }
}
- router.events.on('routeChangeStart', start)
- router.events.on('routeChangeComplete', done)
- router.events.on('routeChangeError', done)
+
+ router.events.on('routeChangeStart', onStart)
+ router.events.on('routeChangeComplete', onComplete)
+ router.events.on('routeChangeError', onError)
return () => {
- router.events.off('routeChangeStart', start)
- router.events.off('routeChangeComplete', done)
- router.events.off('routeChangeError', done)
+ router.events.off('routeChangeStart', onStart)
+ router.events.off('routeChangeComplete', onComplete)
+ router.events.off('routeChangeError', onError)
}
- }, [])
+ }, [router.events])
return
}
diff --git a/apps/web/hooks/useLinkResolver/useLinkResolver.ts b/apps/web/hooks/useLinkResolver/useLinkResolver.ts
index 0fd4939a0f9c..216dcabce6a5 100644
--- a/apps/web/hooks/useLinkResolver/useLinkResolver.ts
+++ b/apps/web/hooks/useLinkResolver/useLinkResolver.ts
@@ -211,6 +211,22 @@ export const routesTemplate = {
is: '/reglugerdir',
en: '',
},
+ ojoiadvert: {
+ is: '/stjornartidindi/nr/[number]',
+ en: '',
+ },
+ ojoisearch: {
+ is: '/stjornartidindi/leit',
+ en: '',
+ },
+ ojoicategories: {
+ is: '/stjornartidindi/malaflokkar',
+ en: '',
+ },
+ ojoihome: {
+ is: '/stjornartidindi',
+ en: '',
+ },
login: {
is: '/innskraning',
en: '/en/login',
diff --git a/apps/web/pages/en/o/university-studies/[...subpage].ts b/apps/web/pages/en/o/university-studies/[...subpage].ts
new file mode 100644
index 000000000000..971f99dd11eb
--- /dev/null
+++ b/apps/web/pages/en/o/university-studies/[...subpage].ts
@@ -0,0 +1,19 @@
+import { GetServerSideProps } from 'next'
+
+import { safelyExtractPathnameFromUrl } from '@island.is/web/utils/safelyExtractPathnameFromUrl'
+
+export const getServerSideProps: GetServerSideProps = async (context) => {
+ const path = safelyExtractPathnameFromUrl(context.req.url).split(
+ '/en/o/university-studies/',
+ )[1]
+ return {
+ redirect: {
+ destination: `/en/university-studies/${path || ''}`,
+ permanent: false,
+ },
+ }
+}
+
+export default () => {
+ return null
+}
diff --git a/apps/web/pages/en/o/university-studies/index.ts b/apps/web/pages/en/o/university-studies/index.ts
new file mode 100644
index 000000000000..582a9c61dddc
--- /dev/null
+++ b/apps/web/pages/en/o/university-studies/index.ts
@@ -0,0 +1,14 @@
+import { GetServerSideProps } from 'next'
+
+export const getServerSideProps: GetServerSideProps = async () => {
+ return {
+ redirect: {
+ destination: '/en/university-studies',
+ permanent: false,
+ },
+ }
+}
+
+export default () => {
+ return null
+}
diff --git a/apps/web/pages/en/university-studies/sitemap.xml.tsx b/apps/web/pages/en/university-studies/sitemap.xml.tsx
new file mode 100644
index 000000000000..d9bb8c35a3b2
--- /dev/null
+++ b/apps/web/pages/en/university-studies/sitemap.xml.tsx
@@ -0,0 +1,14 @@
+import withApollo from '@island.is/web/graphql/withApollo'
+import { withLocale } from '@island.is/web/i18n'
+import Sitemap from '@island.is/web/screens/UniversitySearch/Sitemap'
+import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper'
+
+const Screen = withApollo(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore make web strict
+ withLocale('en')(Sitemap),
+)
+
+export default Screen
+
+export const getServerSideProps = getServerSidePropsWrapper(Screen)
diff --git a/apps/web/pages/haskolanam/sitemap.xml.tsx b/apps/web/pages/haskolanam/sitemap.xml.tsx
new file mode 100644
index 000000000000..ee8e1c95a0c1
--- /dev/null
+++ b/apps/web/pages/haskolanam/sitemap.xml.tsx
@@ -0,0 +1,14 @@
+import withApollo from '@island.is/web/graphql/withApollo'
+import { withLocale } from '@island.is/web/i18n'
+import Sitemap from '@island.is/web/screens/UniversitySearch/Sitemap'
+import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper'
+
+const Screen = withApollo(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore make web strict
+ withLocale('is')(Sitemap),
+)
+
+export default Screen
+
+export const getServerSideProps = getServerSidePropsWrapper(Screen)
diff --git a/apps/web/pages/s/haskolanam/[...subpage].ts b/apps/web/pages/s/haskolanam/[...subpage].ts
new file mode 100644
index 000000000000..df0923ffa106
--- /dev/null
+++ b/apps/web/pages/s/haskolanam/[...subpage].ts
@@ -0,0 +1,19 @@
+import { GetServerSideProps } from 'next'
+
+import { safelyExtractPathnameFromUrl } from '@island.is/web/utils/safelyExtractPathnameFromUrl'
+
+export const getServerSideProps: GetServerSideProps = async (context) => {
+ const path = safelyExtractPathnameFromUrl(context.req.url).split(
+ '/s/haskolanam/',
+ )[1]
+ return {
+ redirect: {
+ destination: `/haskolanam/${path || ''}`,
+ permanent: false,
+ },
+ }
+}
+
+export default () => {
+ return null
+}
diff --git a/apps/web/pages/s/haskolanam/index.ts b/apps/web/pages/s/haskolanam/index.ts
new file mode 100644
index 000000000000..775812c2e035
--- /dev/null
+++ b/apps/web/pages/s/haskolanam/index.ts
@@ -0,0 +1,14 @@
+import { GetServerSideProps } from 'next'
+
+export const getServerSideProps: GetServerSideProps = async () => {
+ return {
+ redirect: {
+ destination: '/haskolanam',
+ permanent: false,
+ },
+ }
+}
+
+export default () => {
+ return null
+}
diff --git a/apps/web/pages/stjornartidindi/index.ts b/apps/web/pages/stjornartidindi/index.ts
new file mode 100644
index 000000000000..b9177340742b
--- /dev/null
+++ b/apps/web/pages/stjornartidindi/index.ts
@@ -0,0 +1,11 @@
+import withApollo from '@island.is/web/graphql/withApollo'
+import { withLocale } from '@island.is/web/i18n'
+import OJOIHome from '@island.is/web/screens/OfficialJournalOfIceland/OJOIHome'
+import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore make web strict
+const Screen = withApollo(withLocale('is')(OJOIHome))
+
+export default Screen
+
+export const getServerSideProps = getServerSidePropsWrapper(Screen)
diff --git a/apps/web/pages/stjornartidindi/leit/index.ts b/apps/web/pages/stjornartidindi/leit/index.ts
new file mode 100644
index 000000000000..533b0d7a161f
--- /dev/null
+++ b/apps/web/pages/stjornartidindi/leit/index.ts
@@ -0,0 +1,11 @@
+import withApollo from '@island.is/web/graphql/withApollo'
+import { withLocale } from '@island.is/web/i18n'
+import OJOISearch from '@island.is/web/screens/OfficialJournalOfIceland/OJOISearch'
+import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore make web strict
+const Screen = withApollo(withLocale('is')(OJOISearch))
+
+export default Screen
+
+export const getServerSideProps = getServerSidePropsWrapper(Screen)
diff --git a/apps/web/pages/stjornartidindi/malaflokkar/index.ts b/apps/web/pages/stjornartidindi/malaflokkar/index.ts
new file mode 100644
index 000000000000..8c016b3bd477
--- /dev/null
+++ b/apps/web/pages/stjornartidindi/malaflokkar/index.ts
@@ -0,0 +1,11 @@
+import withApollo from '@island.is/web/graphql/withApollo'
+import { withLocale } from '@island.is/web/i18n'
+import OJOICategories from '@island.is/web/screens/OfficialJournalOfIceland/OJOICategories'
+import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore make web strict
+const Screen = withApollo(withLocale('is')(OJOICategories))
+
+export default Screen
+
+export const getServerSideProps = getServerSidePropsWrapper(Screen)
diff --git a/apps/web/pages/stjornartidindi/nr/[nr].ts b/apps/web/pages/stjornartidindi/nr/[nr].ts
new file mode 100644
index 000000000000..731a4b2b3a38
--- /dev/null
+++ b/apps/web/pages/stjornartidindi/nr/[nr].ts
@@ -0,0 +1,11 @@
+import withApollo from '@island.is/web/graphql/withApollo'
+import { withLocale } from '@island.is/web/i18n'
+import OJOIAdvert from '@island.is/web/screens/OfficialJournalOfIceland/OJOIAdvert'
+import { getServerSidePropsWrapper } from '@island.is/web/utils/getServerSidePropsWrapper'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore make web strict
+const Screen = withApollo(withLocale('is')(OJOIAdvert))
+
+export default Screen
+
+export const getServerSideProps = getServerSidePropsWrapper(Screen)
diff --git a/apps/web/public/.well-known/apple-app-site-association b/apps/web/public/.well-known/apple-app-site-association
new file mode 100644
index 000000000000..aee27712cdc5
--- /dev/null
+++ b/apps/web/public/.well-known/apple-app-site-association
@@ -0,0 +1,10 @@
+{
+ "applinks": {},
+ "webcredentials": {
+ "apps": [
+ "J3WWZR9JLF.is.island.app",
+ "J3WWZR9JLF.is.island.app.dev"
+ ]
+ },
+ "appclips": {}
+}
diff --git a/apps/web/public/.well-known/assetlinks.json b/apps/web/public/.well-known/assetlinks.json
new file mode 100644
index 000000000000..89d442bcc2ab
--- /dev/null
+++ b/apps/web/public/.well-known/assetlinks.json
@@ -0,0 +1,22 @@
+[
+ {
+ "relation": ["delegate_permission/common.get_login_creds"],
+ "target": {
+ "namespace": "android_app",
+ "package_name": "is.island.app",
+ "sha256_cert_fingerprints": [
+ "12:C2:D3:52:EE:64:69:8E:D7:3E:63:25:D9:FE:E7:6E:AE:1A:9A:EF:8F:37:37:58:BB:71:5E:70:D7:FD:D3:05"
+ ]
+ }
+ },
+ {
+ "relation": ["delegate_permission/common.get_login_creds"],
+ "target": {
+ "namespace": "android_app",
+ "package_name": "is.island.app.dev",
+ "sha256_cert_fingerprints": [
+ "C2:B9:0C:C4:3F:7C:22:2E:65:C6:02:5D:AB:B8:5E:06:AF:F1:4E:AD:FC:FE:CF:46:28:B2:E0:11:23:9B:9C:C4"
+ ]
+ }
+ }
+]
diff --git a/apps/web/screens/AnchorPage/AnchorPage.tsx b/apps/web/screens/AnchorPage/AnchorPage.tsx
index eb2ccdc4c043..cc88a9c19301 100644
--- a/apps/web/screens/AnchorPage/AnchorPage.tsx
+++ b/apps/web/screens/AnchorPage/AnchorPage.tsx
@@ -203,21 +203,7 @@ export const AnchorPage: Screen = ({
/>
- {webRichText(
- content as SliceType[],
- {
- renderComponent: {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore make web strict
- Form: (form) => (
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore make web strict
-
- ),
- },
- },
- activeLocale,
- )}
+ {webRichText(content as SliceType[], undefined, activeLocale)}
diff --git a/apps/web/screens/Article/Article.tsx b/apps/web/screens/Article/Article.tsx
index 590c913624e0..9bcea28a36f7 100644
--- a/apps/web/screens/Article/Article.tsx
+++ b/apps/web/screens/Article/Article.tsx
@@ -474,9 +474,6 @@ const ArticleScreen: Screen = ({
/>
),
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore make web strict
- Form: (form) => ,
},
},
activeLocale,
diff --git a/apps/web/screens/Article/components/ArticleChatPanel/ArticleChatPanel.tsx b/apps/web/screens/Article/components/ArticleChatPanel/ArticleChatPanel.tsx
index 394594c7a2a3..a9ad7d63c9b0 100644
--- a/apps/web/screens/Article/components/ArticleChatPanel/ArticleChatPanel.tsx
+++ b/apps/web/screens/Article/components/ArticleChatPanel/ArticleChatPanel.tsx
@@ -37,6 +37,7 @@ export const ArticleChatPanel = ({
Component = (
)
}
diff --git a/apps/web/screens/Article/components/ArticleChatPanel/config.ts b/apps/web/screens/Article/components/ArticleChatPanel/config.ts
index 20e23ed099aa..c271bb5c8077 100644
--- a/apps/web/screens/Article/components/ArticleChatPanel/config.ts
+++ b/apps/web/screens/Article/components/ArticleChatPanel/config.ts
@@ -10,8 +10,22 @@ export const liveChatIncConfig: Record<
Locale,
Record
> = {
- is: {},
- en: {},
+ is: {
+ // Vinnueftirlitið - Organization
+ '39S5VumPfb1hXBJm3SnE02': {
+ license: '13346703',
+ version: '2.0',
+ showLauncher: false,
+ },
+ },
+ en: {
+ // Vinnueftirlitið - Organization
+ '39S5VumPfb1hXBJm3SnE02': {
+ license: '13346703',
+ version: '2.0',
+ showLauncher: false,
+ },
+ },
}
export const defaultWatsonConfig: Record = {
@@ -801,4 +815,12 @@ export const excludedOrganizationWatsonConfig: string[] = [
// Útlendingastofnun
// https://app.contentful.com/spaces/8k0h54kbe6bj/entries/77rXck3sISbMsUv7BO1PG2
'77rXck3sISbMsUv7BO1PG2',
+
+ // Tryggingastofnun
+ // https://app.contentful.com/spaces/8k0h54kbe6bj/entries/3dgsobJuiJXC1oOxhGpcUY
+ '3dgsobJuiJXC1oOxhGpcUY',
+
+ // HMS
+ // https://app.contentful.com/spaces/8k0h54kbe6bj/entries/53jrbgxPKpbNtordSfEZUK
+ '53jrbgxPKpbNtordSfEZUK',
]
diff --git a/apps/web/screens/LifeEventPage/LifeEventPage.tsx b/apps/web/screens/LifeEventPage/LifeEventPage.tsx
index cc6145d4f370..b3a7f4f343c4 100644
--- a/apps/web/screens/LifeEventPage/LifeEventPage.tsx
+++ b/apps/web/screens/LifeEventPage/LifeEventPage.tsx
@@ -250,13 +250,6 @@ export const LifeEventPage: Screen = ({
lifeEvent?.content as SliceType[],
{
renderComponent: {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore make web strict
- Form: (form) => (
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore make web strict
-
- ),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore make web strict
SectionWithImage: (slice) => {
diff --git a/apps/web/screens/OfficialJournalOfIceland/OJOIAdvert.tsx b/apps/web/screens/OfficialJournalOfIceland/OJOIAdvert.tsx
new file mode 100644
index 000000000000..7f9e8368dd97
--- /dev/null
+++ b/apps/web/screens/OfficialJournalOfIceland/OJOIAdvert.tsx
@@ -0,0 +1,230 @@
+import { useIntl } from 'react-intl'
+import { Locale } from 'locale'
+
+import { Box, Button, Link, Stack, Text } from '@island.is/island-ui/core'
+import {
+ ContentLanguage,
+ CustomPageUniqueIdentifier,
+ OfficialJournalOfIcelandAdvertResponse,
+ Query,
+ QueryGetOrganizationArgs,
+ QueryOfficialJournalOfIcelandAdvertArgs,
+} from '@island.is/web/graphql/schema'
+import { useLinkResolver } from '@island.is/web/hooks'
+import { withMainLayout } from '@island.is/web/layouts/main'
+import { CustomNextError } from '@island.is/web/units/errors'
+
+import {
+ formatDate,
+ OJOIAdvertDisplay,
+ OJOIWrapper,
+} from '../../components/OfficialJournalOfIceland'
+import {
+ CustomScreen,
+ withCustomPageWrapper,
+} from '../CustomPage/CustomPageWrapper'
+import { GET_ORGANIZATION_QUERY } from '../queries'
+import { ADVERT_QUERY } from '../queries/OfficialJournalOfIceland'
+import { m } from './messages'
+
+const OJOIAdvertPage: CustomScreen = ({
+ advert,
+ locale,
+ organization,
+}) => {
+ const { formatMessage } = useIntl()
+ const { linkResolver } = useLinkResolver()
+
+ const baseUrl = linkResolver('ojoihome', [], locale).href
+ const searchUrl = linkResolver('ojoisearch', [], locale).href
+
+ const breadcrumbItems = [
+ {
+ title: 'Ísland.is',
+ href: linkResolver('homepage', [], locale).href,
+ },
+ {
+ title: organization?.title ?? '',
+ href: baseUrl,
+ },
+ {
+ title: formatMessage(m.advert.title),
+ },
+ ]
+
+ return (
+
+
+
+ {formatMessage(m.advert.sidebarTitle)}
+
+
+
+ {formatMessage(m.advert.sidebarDepartment)}
+
+ {advert.department.title}
+
+
+
+
+ {formatMessage(m.advert.sidebarInstitution)}
+
+ {advert.involvedParty.title}
+
+
+
+
+ {formatMessage(m.advert.sidebarCategory)}
+
+
+ {advert.categories.map((c) => c.title).join(', ')}
+
+
+
+
+
+ {formatMessage(m.advert.signatureDate)}
+
+
+ {formatDate(advert.signatureDate, 'dd. MMMM yyyy')}
+
+
+
+
+
+ {formatMessage(m.advert.publicationDate)}
+
+
+ {formatDate(advert.publicationDate, 'dd. MMMM yyyy')}
+
+
+
+
+
+ {advert.document.pdfUrl && (
+
+
+
+
+ {formatMessage(m.advert.getPdf)}
+
+
+
+
+ )}
+
+ }
+ >
+
+
+ )
+}
+
+interface OJOIAdvertProps {
+ advert: OfficialJournalOfIcelandAdvertResponse['advert']
+ locale: Locale
+ organization?: Query['getOrganization']
+}
+
+const OJOIAdvert: CustomScreen = ({
+ advert,
+ locale,
+ organization,
+ customPageData,
+}) => {
+ return (
+
+ )
+}
+
+OJOIAdvert.getProps = async ({
+ apolloClient,
+ locale,
+ query,
+ customPageData,
+}) => {
+ const organizationSlug = 'stjornartidindi'
+
+ const [
+ {
+ data: { officialJournalOfIcelandAdvert },
+ },
+ {
+ data: { getOrganization },
+ },
+ ] = await Promise.all([
+ apolloClient.query({
+ query: ADVERT_QUERY,
+ variables: {
+ params: {
+ id: query.nr as string,
+ },
+ },
+ }),
+ apolloClient.query({
+ query: GET_ORGANIZATION_QUERY,
+ variables: {
+ input: {
+ slug: organizationSlug,
+ lang: locale as ContentLanguage,
+ },
+ },
+ }),
+ ])
+
+ if (!getOrganization?.hasALandingPage) {
+ throw new CustomNextError(404, 'Organization page not found')
+ }
+
+ if (!officialJournalOfIcelandAdvert?.advert) {
+ throw new CustomNextError(404, 'OJOI advert not found')
+ }
+
+ return {
+ advert: officialJournalOfIcelandAdvert.advert,
+ organization: getOrganization,
+ locale: locale as Locale,
+ showSearchInHeader: false,
+ themeConfig: {
+ footerVersion: 'organization',
+ },
+ }
+}
+
+export default withMainLayout(
+ withCustomPageWrapper(
+ CustomPageUniqueIdentifier.OfficialJournalOfIceland,
+ OJOIAdvert,
+ ),
+)
diff --git a/apps/web/screens/OfficialJournalOfIceland/OJOICategories.tsx b/apps/web/screens/OfficialJournalOfIceland/OJOICategories.tsx
new file mode 100644
index 000000000000..754b8d791440
--- /dev/null
+++ b/apps/web/screens/OfficialJournalOfIceland/OJOICategories.tsx
@@ -0,0 +1,454 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useIntl } from 'react-intl'
+import { Locale } from 'locale'
+import debounce from 'lodash/debounce'
+import { useRouter } from 'next/router'
+
+import {
+ Box,
+ Button,
+ Divider,
+ Inline,
+ Input,
+ LinkV2,
+ Select,
+ Stack,
+ Table as T,
+ Tag,
+ Text,
+} from '@island.is/island-ui/core'
+import { debounceTime } from '@island.is/shared/constants'
+import {
+ ContentLanguage,
+ CustomPageUniqueIdentifier,
+ OfficialJournalOfIcelandAdvertCategory,
+ OfficialJournalOfIcelandAdvertEntity,
+ OfficialJournalOfIcelandAdvertMainCategory,
+ Query,
+ QueryGetOrganizationArgs,
+ QueryOfficialJournalOfIcelandCategoriesArgs,
+ QueryOfficialJournalOfIcelandDepartmentsArgs,
+ QueryOfficialJournalOfIcelandMainCategoriesArgs,
+} from '@island.is/web/graphql/schema'
+import { useLinkResolver } from '@island.is/web/hooks'
+import { withMainLayout } from '@island.is/web/layouts/main'
+import { CustomNextError } from '@island.is/web/units/errors'
+
+import {
+ emptyOption,
+ EntityOption,
+ findValueOption,
+ mapEntityToOptions,
+ OJOIWrapper,
+ removeEmptyFromObject,
+ sortCategories,
+ splitArrayIntoGroups,
+} from '../../components/OfficialJournalOfIceland'
+import {
+ CustomScreen,
+ withCustomPageWrapper,
+} from '../CustomPage/CustomPageWrapper'
+import { GET_ORGANIZATION_QUERY } from '../queries'
+import {
+ CATEGORIES_QUERY,
+ DEPARTMENTS_QUERY,
+ MAIN_CATEGORIES_QUERY,
+} from '../queries/OfficialJournalOfIceland'
+import { m } from './messages'
+type MalaflokkarType = Array<{
+ letter: string
+ categories: EntityOption[]
+}>
+
+const initialState = {
+ q: '',
+ stafur: '',
+ deild: '',
+ yfirflokkur: '',
+}
+
+const OJOICategoriesPage: CustomScreen = ({
+ mainCategories,
+ categories,
+ departments,
+ organization,
+ locale,
+}) => {
+ const { formatMessage } = useIntl()
+ const router = useRouter()
+ const { linkResolver } = useLinkResolver()
+
+ const baseUrl = linkResolver('ojoihome', [], locale).href
+ const searchUrl = linkResolver('ojoisearch', [], locale).href
+ const categoriesUrl = linkResolver('ojoicategories', [], locale).href
+
+ const [searchState, setSearchState] = useState(initialState)
+
+ const categoriesOptions = mapEntityToOptions(categories)
+
+ const sortedCategories = useMemo(() => {
+ return sortCategories(categoriesOptions)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [categoriesOptions])
+
+ const filterCategories = useCallback(
+ (initial?: boolean) => {
+ const filtered: MalaflokkarType = []
+ sortedCategories.forEach((cat) => {
+ const letter = cat.label.slice(0, 1).toUpperCase()
+
+ const qMatch =
+ !initial && searchState.q
+ ? cat.label.toLowerCase().includes(searchState.q.toLowerCase())
+ : true
+ const letterMatch =
+ !initial && searchState.stafur
+ ? searchState.stafur.split('').includes(letter)
+ : true
+ const deildMatch =
+ !initial && searchState.deild
+ ? cat.department === searchState.deild
+ : true
+ const flokkurMatch =
+ !initial && searchState.yfirflokkur
+ ? cat.mainCategory === searchState.yfirflokkur
+ : true
+
+ if (qMatch && letterMatch && deildMatch && flokkurMatch) {
+ if (!filtered.find((f) => f.letter === letter)) {
+ filtered.push({ letter, categories: [cat] })
+ } else {
+ filtered.find((f) => f.letter === letter)?.categories.push(cat)
+ }
+ }
+ })
+
+ return filtered
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },
+ [searchState, sortedCategories],
+ )
+
+ const initialCategories = useMemo(() => {
+ return filterCategories(true)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sortedCategories])
+
+ const [activeCategories, setCategories] = useState(initialCategories)
+
+ useEffect(() => {
+ const searchParams = new URLSearchParams(document.location.search)
+ setSearchState({
+ q: searchParams.get('q') ?? '',
+ stafur: searchParams.get('stafur') ?? '',
+ deild: searchParams.get('deild') ?? '',
+ yfirflokkur: searchParams.get('yfirflokkur') ?? '',
+ })
+ }, [])
+
+ useEffect(() => {
+ if (
+ searchState.q ||
+ searchState.stafur ||
+ searchState.deild ||
+ searchState.yfirflokkur
+ ) {
+ setCategories(filterCategories())
+ } else {
+ setCategories(initialCategories)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchState])
+
+ const breadcrumbItems = [
+ {
+ title: 'Ísland.is',
+ href: linkResolver('homepage', [], locale).href,
+ },
+ {
+ title: organization?.title ?? '',
+ href: baseUrl,
+ },
+ {
+ title: 'Málaflokkar',
+ },
+ ]
+
+ const updateSearchParams = useMemo(() => {
+ return debounce((state: Record) => {
+ router.replace(
+ categoriesUrl,
+ {
+ query: removeEmptyFromObject(state),
+ },
+ { shallow: true },
+ )
+ }, debounceTime.search)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const updateSearchState = (key: string, value: string) => {
+ const newState = {
+ ...searchState,
+ [key]: value,
+ }
+ setSearchState(newState)
+ updateSearchParams(newState)
+ }
+
+ const toggleLetter = (letter: string) => {
+ let letters = searchState.stafur.split('')
+ if (letters.includes(letter)) {
+ letters = letters.filter((l) => l !== letter)
+ } else {
+ letters.push(letter)
+ }
+ updateSearchState('stafur', letters.join(''))
+ }
+
+ const resetFilter = () => {
+ setSearchState(initialState)
+ updateSearchParams(initialState)
+ }
+
+ const departmentsOptions = mapEntityToOptions(departments)
+ const mainCategoriesOptions = mapEntityToOptions(mainCategories)
+
+ return (
+
+
+ {formatMessage(m.categories.searchTitle)}
+
+ updateSearchState('q', e.target.value)}
+ />
+
+
+
+
+
+ {formatMessage(m.categories.filterTitle)}
+
+
+ {formatMessage(m.categories.clearFilter)}
+
+
+
+ updateSearchState('deild', v?.value ?? '')}
+ />
+
+ updateSearchState('yfirflokkur', v?.value ?? '')}
+ />
+
+
+ }
+ breadcrumbItems={breadcrumbItems}
+ >
+
+
+ {initialCategories.map((c) => (
+ {
+ toggleLetter(c.letter)
+ }}
+ variant={
+ searchState.stafur.includes(c.letter)
+ ? 'blue'
+ : !activeCategories.find((cat) => cat.letter === c.letter)
+ ? 'disabled'
+ : 'white'
+ }
+ outlined={searchState.stafur.includes(c.letter) ? false : true}
+ >
+ {'\u00A0'}
+ {c.letter}
+ {'\u00A0'}
+
+ ))}
+
+ {activeCategories.length === 0 ? (
+ {formatMessage(m.categories.notFoundMessage)}
+ ) : (
+ activeCategories.map((c) => {
+ const groups = splitArrayIntoGroups(c.categories, 3)
+ return (
+
+
+
+ {c.letter}
+
+
+
+ {groups.map((group) => (
+
+ {group.map((cat) => (
+
+
+ {cat.label}
+
+
+ ))}
+
+ ))}
+
+
+ )
+ })
+ )}
+
+
+ )
+}
+
+interface OJOICategoriesProps {
+ mainCategories?: OfficialJournalOfIcelandAdvertMainCategory[]
+ categories?: Array
+ departments?: Array
+ organization?: Query['getOrganization']
+ locale: Locale
+}
+
+const OJOICategories: CustomScreen = ({
+ mainCategories,
+ departments,
+ categories,
+ organization,
+ customPageData,
+ locale,
+}) => {
+ return (
+
+ )
+}
+
+OJOICategories.getProps = async ({ apolloClient, locale }) => {
+ const organizationSlug = 'stjornartidindi'
+
+ const [
+ {
+ data: { officialJournalOfIcelandMainCategories },
+ },
+ {
+ data: { officialJournalOfIcelandCategories },
+ },
+ {
+ data: { officialJournalOfIcelandDepartments },
+ },
+ {
+ data: { getOrganization },
+ },
+ ] = await Promise.all([
+ apolloClient.query({
+ query: MAIN_CATEGORIES_QUERY,
+ variables: {
+ params: {},
+ },
+ }),
+ apolloClient.query({
+ query: CATEGORIES_QUERY,
+ variables: {
+ params: {},
+ },
+ }),
+ apolloClient.query({
+ query: DEPARTMENTS_QUERY,
+ variables: {
+ params: {},
+ },
+ }),
+ apolloClient.query({
+ query: GET_ORGANIZATION_QUERY,
+ variables: {
+ input: {
+ slug: organizationSlug,
+ lang: locale as ContentLanguage,
+ },
+ },
+ }),
+ ])
+
+ if (!getOrganization?.hasALandingPage) {
+ throw new CustomNextError(404, 'Organization page not found')
+ }
+
+ return {
+ mainCategories: officialJournalOfIcelandMainCategories?.mainCategories,
+ categories: officialJournalOfIcelandCategories?.categories,
+ departments: officialJournalOfIcelandDepartments?.departments,
+ organization: getOrganization,
+ locale: locale as Locale,
+ showSearchInHeader: false,
+ themeConfig: {
+ footerVersion: 'organization',
+ },
+ }
+}
+
+export default withMainLayout(
+ withCustomPageWrapper(
+ CustomPageUniqueIdentifier.OfficialJournalOfIceland,
+ OJOICategories,
+ ),
+)
diff --git a/apps/web/screens/OfficialJournalOfIceland/OJOIHome.tsx b/apps/web/screens/OfficialJournalOfIceland/OJOIHome.tsx
new file mode 100644
index 000000000000..40554df056a1
--- /dev/null
+++ b/apps/web/screens/OfficialJournalOfIceland/OJOIHome.tsx
@@ -0,0 +1,231 @@
+import { useIntl } from 'react-intl'
+import { Locale } from 'locale'
+import NextLink from 'next/link'
+
+import {
+ ArrowLink,
+ Box,
+ Breadcrumbs,
+ CategoryCard,
+ GridColumn,
+ GridContainer,
+ GridRow,
+ Stack,
+ Text,
+} from '@island.is/island-ui/core'
+import { SLICE_SPACING } from '@island.is/web/constants'
+import {
+ ContentLanguage,
+ CustomPageUniqueIdentifier,
+ OfficialJournalOfIcelandAdvertMainCategory,
+ Query,
+ QueryGetOrganizationArgs,
+ QueryOfficialJournalOfIcelandMainCategoriesArgs,
+} from '@island.is/web/graphql/schema'
+import { useLinkResolver } from '@island.is/web/hooks'
+import { withMainLayout } from '@island.is/web/layouts/main'
+import { CustomNextError } from '@island.is/web/units/errors'
+
+import {
+ OJOIHomeIntro,
+ OJOIWrapper,
+} from '../../components/OfficialJournalOfIceland'
+import {
+ CustomScreen,
+ withCustomPageWrapper,
+} from '../CustomPage/CustomPageWrapper'
+import { GET_ORGANIZATION_QUERY } from '../queries'
+import { MAIN_CATEGORIES_QUERY } from '../queries/OfficialJournalOfIceland'
+import { m } from './messages'
+
+const OJOIHomePage: CustomScreen = ({
+ mainCategories,
+ organization,
+ locale,
+}) => {
+ const { formatMessage } = useIntl()
+ const { linkResolver } = useLinkResolver()
+
+ const baseUrl = linkResolver('ojoihome', [], locale).href
+ const searchUrl = linkResolver('ojoisearch', [], locale).href
+ const categoriesUrl = linkResolver('ojoicategories', [], locale).href
+
+ const breadcrumbItems = [
+ {
+ title: 'Ísland.is',
+ href: linkResolver('homepage', [], locale).href,
+ },
+ {
+ title: organization?.title ?? '',
+ href: baseUrl,
+ },
+ ]
+
+ return (
+
+
+ {
+ return item?.href ? (
+
+ {link}
+
+ ) : (
+ link
+ )
+ }}
+ />
+ )
+ }
+ />
+
+
+
+
+ {formatMessage(m.home.mainCategories)}
+
+ {formatMessage(m.home.allCategories)}
+
+
+
+
+ {mainCategories?.map((y, i) => (
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+interface OJOIHomeProps {
+ mainCategories?: OfficialJournalOfIcelandAdvertMainCategory[]
+ organization?: Query['getOrganization']
+ locale: Locale
+}
+
+const OJOIHome: CustomScreen = ({
+ mainCategories,
+ organization,
+ customPageData,
+ locale,
+}) => {
+ return (
+
+ )
+}
+
+OJOIHome.getProps = async ({ apolloClient, locale }) => {
+ const organizationSlug = 'stjornartidindi'
+
+ const [
+ {
+ data: { officialJournalOfIcelandMainCategories },
+ },
+ {
+ data: { getOrganization },
+ },
+ ] = await Promise.all([
+ apolloClient.query({
+ query: MAIN_CATEGORIES_QUERY,
+ variables: {
+ params: {},
+ },
+ }),
+ apolloClient.query({
+ query: GET_ORGANIZATION_QUERY,
+ variables: {
+ input: {
+ slug: organizationSlug,
+ lang: locale as ContentLanguage,
+ },
+ },
+ }),
+ ])
+
+ if (!getOrganization?.hasALandingPage) {
+ throw new CustomNextError(404, 'Organization page not found')
+ }
+
+ return {
+ mainCategories: officialJournalOfIcelandMainCategories.mainCategories,
+ organization: getOrganization,
+ locale: locale as Locale,
+ showSearchInHeader: false,
+ themeConfig: {
+ footerVersion: 'organization',
+ },
+ }
+}
+
+export default withMainLayout(
+ withCustomPageWrapper(
+ CustomPageUniqueIdentifier.OfficialJournalOfIceland,
+ OJOIHome,
+ ),
+)
diff --git a/apps/web/screens/OfficialJournalOfIceland/OJOISearch.tsx b/apps/web/screens/OfficialJournalOfIceland/OJOISearch.tsx
new file mode 100644
index 000000000000..27806fbd951e
--- /dev/null
+++ b/apps/web/screens/OfficialJournalOfIceland/OJOISearch.tsx
@@ -0,0 +1,509 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useIntl } from 'react-intl'
+import { Locale } from 'locale'
+import debounce from 'lodash/debounce'
+import { useRouter } from 'next/router'
+import { useLazyQuery } from '@apollo/client'
+
+import {
+ Box,
+ Button,
+ DatePicker,
+ Divider,
+ Input,
+ Select,
+ Stack,
+ Text,
+} from '@island.is/island-ui/core'
+import { debounceTime } from '@island.is/shared/constants'
+import {
+ ContentLanguage,
+ CustomPageUniqueIdentifier,
+ OfficialJournalOfIcelandAdvert,
+ OfficialJournalOfIcelandAdvertCategory,
+ OfficialJournalOfIcelandAdvertEntity,
+ OfficialJournalOfIcelandAdvertsResponse,
+ OfficialJournalOfIcelandAdvertType,
+ Query,
+ QueryGetOrganizationArgs,
+ QueryOfficialJournalOfIcelandAdvertsArgs,
+ QueryOfficialJournalOfIcelandCategoriesArgs,
+ QueryOfficialJournalOfIcelandDepartmentsArgs,
+ QueryOfficialJournalOfIcelandInstitutionsArgs,
+ QueryOfficialJournalOfIcelandTypesArgs,
+} from '@island.is/web/graphql/schema'
+import { useLinkResolver } from '@island.is/web/hooks'
+import { withMainLayout } from '@island.is/web/layouts/main'
+import { CustomNextError } from '@island.is/web/units/errors'
+
+import {
+ emptyOption,
+ findValueOption,
+ mapEntityToOptions,
+ OJOISearchGridView,
+ OJOISearchListView,
+ OJOIWrapper,
+ removeEmptyFromObject,
+} from '../../components/OfficialJournalOfIceland'
+import {
+ CustomScreen,
+ withCustomPageWrapper,
+} from '../CustomPage/CustomPageWrapper'
+import { GET_ORGANIZATION_QUERY } from '../queries'
+import {
+ ADVERTS_QUERY,
+ CATEGORIES_QUERY,
+ DEPARTMENTS_QUERY,
+ INSTITUTIONS_QUERY,
+ TYPES_QUERY,
+} from '../queries/OfficialJournalOfIceland'
+import { m } from './messages'
+
+const initialState = {
+ sida: '',
+ q: '',
+ deild: '', // department
+ tegund: '', // type
+ timabil: '', // dateFrom - dateTo
+ malaflokkur: '', // category
+ stofnun: '', // involvedParty
+ dagsFra: '',
+ dagsTil: '',
+}
+
+const OJOISearchPage: CustomScreen = ({
+ initialAdverts,
+ categories,
+ departments,
+ types,
+ institutions,
+ organization,
+ locale,
+}) => {
+ const { formatMessage } = useIntl()
+ const router = useRouter()
+ const { linkResolver } = useLinkResolver()
+
+ const [adverts, setAdverts] = useState(initialAdverts)
+
+ const [searchState, setSearchState] = useState(initialState)
+ const [listView, setListView] = useState(false)
+
+ const baseUrl = linkResolver('ojoihome', [], locale).href
+ const searchUrl = linkResolver('ojoisearch', [], locale).href
+
+ const [getAdverts] = useLazyQuery<
+ {
+ officialJournalOfIcelandAdverts: OfficialJournalOfIcelandAdvertsResponse
+ },
+ QueryOfficialJournalOfIcelandAdvertsArgs
+ >(ADVERTS_QUERY, { fetchPolicy: 'no-cache' })
+
+ useEffect(() => {
+ const searchParams = new URLSearchParams(document.location.search)
+ setSearchState({
+ sida: searchParams.get('sida') ?? '',
+ q: searchParams.get('q') ?? '',
+ deild: searchParams.get('deild') ?? '',
+ tegund: searchParams.get('tegund') ?? '',
+ timabil: searchParams.get('timabil') ?? '',
+ malaflokkur: searchParams.get('malaflokkur') ?? '',
+ stofnun: searchParams.get('stofnun') ?? '',
+ dagsFra: searchParams.get('dagsFra') ?? '',
+ dagsTil: searchParams.get('dagsTil') ?? '',
+ })
+ }, [])
+
+ const fetchAdverts = useMemo(() => {
+ return debounce((state: typeof initialState) => {
+ getAdverts({
+ variables: {
+ input: {
+ search: state.q,
+ page: state.sida ? parseInt(state.sida) : undefined,
+ department: state.deild ? [state.deild] : undefined,
+ type: state.tegund ? [state.tegund] : undefined,
+ category: state.malaflokkur ? [state.malaflokkur] : undefined,
+ involvedParty: state.stofnun ? [state.stofnun] : undefined,
+ dateFrom: state.dagsFra ? new Date(state.dagsFra) : undefined,
+ dateTo: state.dagsTil ? new Date(state.dagsTil) : undefined,
+ },
+ },
+ })
+ .then((res) => {
+ if (res.data) {
+ setAdverts(res.data.officialJournalOfIcelandAdverts.adverts)
+ } else if (res.error) {
+ setAdverts([])
+ console.error('Error fetching Adverts', res.error)
+ }
+ })
+ .catch((err) => {
+ setAdverts([])
+ console.error('Error fetching Adverts', { err })
+ })
+ }, debounceTime.search)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ useEffect(() => {
+ const isEmpty = !Object.entries(searchState).filter(([_, v]) => !!v).length
+ if (isEmpty) {
+ setAdverts(initialAdverts)
+ } else {
+ fetchAdverts(searchState)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchState])
+
+ const breadcrumbItems = [
+ {
+ title: 'Ísland.is',
+ href: linkResolver('homepage', [], locale).href,
+ },
+ {
+ title: organization?.title ?? '',
+ href: baseUrl,
+ },
+ {
+ title: 'Leitarniðurstöður',
+ },
+ ]
+
+ const updateSearchParams = useMemo(() => {
+ return debounce((state: Record) => {
+ router.replace(
+ searchUrl,
+ {
+ query: removeEmptyFromObject(state),
+ },
+ { shallow: true },
+ )
+ }, debounceTime.search)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const updateSearchState = (key: keyof typeof initialState, value: string) => {
+ const newState = {
+ ...searchState,
+ [key]: value,
+ }
+ setSearchState(newState)
+ updateSearchParams(newState)
+ }
+
+ const resetFilter = () => {
+ setSearchState(initialState)
+ updateSearchParams(initialState)
+ setAdverts(initialAdverts)
+ }
+
+ const categoriesOptions = mapEntityToOptions(categories)
+ const departmentsOptions = mapEntityToOptions(departments)
+ const typesOptions = mapEntityToOptions(types)
+ const institutionsOptions = mapEntityToOptions(institutions)
+
+ return (
+
+
+ Leit
+
+ updateSearchState('q', e.target.value)}
+ />
+
+
+
+
+ {formatMessage(m.search.filterTitle)}
+
+ {formatMessage(m.search.clearFilter)}
+
+
+
+ updateSearchState('deild', v?.value ?? '')}
+ />
+
+ updateSearchState('tegund', v?.value ?? '')}
+ />
+
+ updateSearchState('malaflokkur', v?.value ?? '')}
+ />
+
+
+ updateSearchState(
+ 'dagsFra',
+ date ? date.toISOString().slice(0, 10) : '',
+ )
+ }
+ />
+
+
+ updateSearchState(
+ 'dagsTil',
+ date ? date.toISOString().slice(0, 10) : '',
+ )
+ }
+ />
+
+ updateSearchState('stofnun', v?.value ?? '')}
+ />
+
+
+ }
+ breadcrumbItems={breadcrumbItems}
+ >
+ {adverts?.length ? (
+
+ setListView(!listView)}
+ size="small"
+ iconType="outline"
+ icon={listView ? 'copy' : 'menu'}
+ variant="utility"
+ >
+ {listView
+ ? formatMessage(m.search.cardView)
+ : formatMessage(m.search.listView)}
+
+
+ {listView ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+
+ {formatMessage(m.search.notFoundTitle)}
+
+ {formatMessage(m.search.notFoundMessage)}
+
+ )}
+
+ )
+}
+
+interface OJOISearchProps {
+ initialAdverts?: OfficialJournalOfIcelandAdvert[]
+ categories?: Array
+ departments?: Array
+ types?: Array
+ institutions?: Array
+ organization?: Query['getOrganization']
+ locale: Locale
+}
+
+const OJOISearch: CustomScreen = ({
+ initialAdverts,
+ categories,
+ departments,
+ types,
+ institutions,
+ organization,
+ locale,
+ customPageData,
+}) => {
+ return (
+
+ )
+}
+
+OJOISearch.getProps = async ({ apolloClient, locale }) => {
+ const organizationSlug = 'stjornartidindi'
+
+ const [
+ {
+ data: { officialJournalOfIcelandAdverts },
+ },
+ {
+ data: { officialJournalOfIcelandCategories },
+ },
+ {
+ data: { officialJournalOfIcelandDepartments },
+ },
+ {
+ data: { officialJournalOfIcelandTypes },
+ },
+ {
+ data: { officialJournalOfIcelandInstitutions },
+ },
+ {
+ data: { getOrganization },
+ },
+ ] = await Promise.all([
+ apolloClient.query({
+ query: ADVERTS_QUERY,
+ variables: {
+ input: {},
+ },
+ }),
+ apolloClient.query({
+ query: CATEGORIES_QUERY,
+ variables: {
+ params: {},
+ },
+ }),
+ apolloClient.query({
+ query: DEPARTMENTS_QUERY,
+ variables: {
+ params: {},
+ },
+ }),
+ apolloClient.query({
+ query: TYPES_QUERY,
+ variables: {
+ params: {},
+ },
+ }),
+ apolloClient.query({
+ query: INSTITUTIONS_QUERY,
+ variables: {
+ params: {},
+ },
+ }),
+ apolloClient.query({
+ query: GET_ORGANIZATION_QUERY,
+ variables: {
+ input: {
+ slug: organizationSlug,
+ lang: locale as ContentLanguage,
+ },
+ },
+ }),
+ ])
+
+ if (!getOrganization?.hasALandingPage) {
+ throw new CustomNextError(404, 'Organization page not found')
+ }
+
+ return {
+ initialAdverts: officialJournalOfIcelandAdverts.adverts,
+ categories: officialJournalOfIcelandCategories?.categories,
+ departments: officialJournalOfIcelandDepartments?.departments,
+ types: officialJournalOfIcelandTypes?.types,
+ institutions: officialJournalOfIcelandInstitutions?.institutions,
+ organization: getOrganization,
+ locale: locale as Locale,
+ showSearchInHeader: false,
+ themeConfig: {
+ footerVersion: 'organization',
+ },
+ }
+}
+
+export default withMainLayout(
+ withCustomPageWrapper(
+ CustomPageUniqueIdentifier.OfficialJournalOfIceland,
+ OJOISearch,
+ ),
+)
diff --git a/apps/web/screens/OfficialJournalOfIceland/messages.ts b/apps/web/screens/OfficialJournalOfIceland/messages.ts
new file mode 100644
index 000000000000..7e668a87a12b
--- /dev/null
+++ b/apps/web/screens/OfficialJournalOfIceland/messages.ts
@@ -0,0 +1,248 @@
+import { defineMessages } from 'react-intl'
+
+export const m = {
+ general: defineMessages({
+ seeMore: {
+ id: 'web.ojoi:general.seeMore',
+ defaultMessage: 'Skoða nánar',
+ },
+ }),
+ home: defineMessages({
+ title: {
+ id: 'web.ojoi:home.title',
+ defaultMessage: 'Stjórnartíðindi',
+ },
+ description: {
+ id: 'web.ojoi:home.description',
+ defaultMessage:
+ 'Um útgáfu Stjórnartíðinda gilda lög um Stjórnartíðindi og Lögbirtingablað nr. 15/2005.',
+ },
+ featuredImage: {
+ id: 'web.ojoi:home.featuredImage',
+ defaultMessage:
+ 'https://images.ctfassets.net/8k0h54kbe6bj/5LqU9yD9nzO5oOijpZF0K0/b595e1cf3e72bc97b2f9d869a53f5da9/LE_-_Jobs_-_S3.png',
+ },
+
+ inputPlaceholder: {
+ id: 'web.ojoi:home.inputPlaceholder',
+ defaultMessage: 'Leitaðu í stjórnartíðindum',
+ },
+ shortcuts: {
+ id: 'web.ojoi:home.shortcuts',
+ defaultMessage: 'Flýtileiðir',
+ },
+ mainCategories: {
+ id: 'web.ojoi:home.mainCategories',
+ defaultMessage: 'Yfirflokkar',
+ },
+ allCategories: {
+ id: 'web.ojoi:home.allCategories',
+ defaultMessage: 'Málaflokkar A-Ö',
+ },
+ }),
+
+ search: defineMessages({
+ title: {
+ id: 'web.ojoi:search.title',
+ defaultMessage: 'Leit í Stjórnartíðindum',
+ },
+ description: {
+ id: 'web.ojoi:search.description',
+ defaultMessage:
+ 'Um útgáfu Stjórnartíðinda gilda lög um Stjórnartíðindi og Lögbirtingablað nr. 15/2005.',
+ },
+
+ inputPlaceholder: {
+ id: 'web.ojoi:search.inputPlaceholder',
+ defaultMessage: 'Leit í Stjórnartíðindum',
+ },
+ filterTitle: {
+ id: 'web.ojoi:search.filterTitle',
+ defaultMessage: 'Síun',
+ },
+ clearFilter: {
+ id: 'web.ojoi:search.clearFilter',
+ defaultMessage: 'Hreinsa síun',
+ },
+
+ departmentLabel: {
+ id: 'web.ojoi:search.departmentLabel',
+ defaultMessage: 'Deild',
+ },
+ departmentPlaceholder: {
+ id: 'web.ojoi:search.departmentPlaceholder',
+ defaultMessage: 'Veldu deild',
+ },
+ departmentAll: {
+ id: 'web.ojoi:search.departmentAll',
+ defaultMessage: 'Allar deildir',
+ },
+ typeLabel: {
+ id: 'web.ojoi:search.typeLabel',
+ defaultMessage: 'Tegund',
+ },
+ typePlaceholder: {
+ id: 'web.ojoi:search.typePlaceholder',
+ defaultMessage: 'Veldu tegund',
+ },
+ typeAll: {
+ id: 'web.ojoi:search.typeAll',
+ defaultMessage: 'Allar tegundir',
+ },
+ categoriesLabel: {
+ id: 'web.ojoi:search.categoriesLabel',
+ defaultMessage: 'Málaflokkur',
+ },
+ categoriesPlaceholder: {
+ id: 'web.ojoi:search.categoriesPlaceholder',
+ defaultMessage: 'Veldu málaflokk',
+ },
+ categoriesAll: {
+ id: 'web.ojoi:search.categoriesAll',
+ defaultMessage: 'Allir flokkar',
+ },
+ dateFromLabel: {
+ id: 'web.ojoi:search.dateFromLabel',
+ defaultMessage: 'Dags. frá',
+ },
+ dateFromPlaceholder: {
+ id: 'web.ojoi:search.dateFromPlaceholder',
+ defaultMessage: 'Veldu upphafsdagsetningu',
+ },
+ dateToLabel: {
+ id: 'web.ojoi:search.dateToLabel',
+ defaultMessage: 'Dags. til',
+ },
+ dateToPlaceholder: {
+ id: 'web.ojoi:search.dateToPlaceholder',
+ defaultMessage: 'Veldu lokadagsetningu',
+ },
+ institutionLabel: {
+ id: 'web.ojoi:search.institutionLabel',
+ defaultMessage: 'Stofnun',
+ },
+ institutionPlaceholder: {
+ id: 'web.ojoi:search.institutionPlaceholder',
+ defaultMessage: 'Veldu stofnun',
+ },
+ institutionAll: {
+ id: 'web.ojoi:search.institutionAll',
+ defaultMessage: 'Allir stofnanir',
+ },
+
+ listView: {
+ id: 'web.ojoi:search.listView',
+ defaultMessage: 'Sýna sem lista',
+ },
+ cardView: {
+ id: 'web.ojoi:search.cardView',
+ defaultMessage: 'Sýna sem spjöld',
+ },
+
+ notFoundTitle: {
+ id: 'web.ojoi:search.notFoundTitle',
+ defaultMessage: 'Engin mál fundust',
+ },
+ notFoundMessage: {
+ id: 'web.ojoi:search.notFoundMessage',
+ defaultMessage: 'Vinsamlega endurskoðaðu leitarskilyrði',
+ },
+ }),
+
+ categories: defineMessages({
+ title: {
+ id: 'web.ojoi:categories.title',
+ defaultMessage: 'Málaflokkar Stjórnartíðinda',
+ },
+ description: {
+ id: 'web.ojoi:categories.description',
+ defaultMessage:
+ 'Um útgáfu Stjórnartíðinda gilda lög um Stjórnartíðindi og Lögbirtingablað nr. 15/2005.',
+ },
+ searchTitle: {
+ id: 'web.ojoi:categories.searchTitle',
+ defaultMessage: 'Leit',
+ },
+ searchPlaceholder: {
+ id: 'web.ojoi:categories.searchPlaceholder',
+ defaultMessage: 'Leit í flokkum',
+ },
+ filterTitle: {
+ id: 'web.ojoi:categories.filterTitle',
+ defaultMessage: 'Síun',
+ },
+ clearFilter: {
+ id: 'web.ojoi:categories.clearFilter',
+ defaultMessage: 'Hreinsa síun',
+ },
+ departmentLabel: {
+ id: 'web.ojoi:categories.departmentLabel',
+ defaultMessage: 'Deild',
+ },
+ departmentPlaceholder: {
+ id: 'web.ojoi:categories.departmentPlaceholder',
+ defaultMessage: 'Veldu deild',
+ },
+ departmentAll: {
+ id: 'web.ojoi:categories.departmentAll',
+ defaultMessage: 'Allar deildir',
+ },
+ mainCategoryLabel: {
+ id: 'web.ojoi:categories.mainCategoryLabel',
+ defaultMessage: 'Yfirflokkur',
+ },
+ mainCategoryPlaceholder: {
+ id: 'web.ojoi:categories.mainCategoryPlaceholder',
+ defaultMessage: 'Veldu yfirflokk',
+ },
+ mainCategoryAll: {
+ id: 'web.ojoi:categories.mainCategoryAll',
+ defaultMessage: 'Allir flokkar',
+ },
+ notFoundMessage: {
+ id: 'web.ojoi:categories.notFoundMessage',
+ defaultMessage: 'Ekkert fannst fyrir þessi leitarskilyrði',
+ },
+ }),
+
+ advert: defineMessages({
+ title: {
+ id: 'web.ojoi:advert.title',
+ defaultMessage: 'Auglýsing',
+ },
+ description: {
+ id: 'web.ojoi:advert.description',
+ defaultMessage:
+ 'Sé munur á uppsetningu texta hér að neðan og í PDF skjali gildir PDF skjalið.',
+ },
+
+ sidebarTitle: {
+ id: 'web.ojoi:advert.sidebarTitle',
+ defaultMessage: 'Upplýsingar um auglýsingu',
+ },
+ sidebarDepartment: {
+ id: 'web.ojoi:advert.sidebarDepartment',
+ defaultMessage: 'Deild',
+ },
+ sidebarInstitution: {
+ id: 'web.ojoi:advert.sidebarInstitution',
+ defaultMessage: 'Stofnun',
+ },
+ sidebarCategory: {
+ id: 'web.ojoi:advert.sidebarCategory',
+ defaultMessage: 'Málaflokkur',
+ },
+ signatureDate: {
+ id: 'web.ojoi:advert.signatureDate',
+ defaultMessage: 'Skráningardagur',
+ },
+ publicationDate: {
+ id: 'web.ojoi:advert.publicationDate',
+ defaultMessage: 'Útgáfudagur',
+ },
+ getPdf: {
+ id: 'web.ojoi:advert.getPdf',
+ defaultMessage: 'Sækja PDF',
+ },
+ }),
+}
diff --git a/apps/web/screens/Organization/Home/Home.tsx b/apps/web/screens/Organization/Home/Home.tsx
index c9f514baefec..4964b3c5d319 100644
--- a/apps/web/screens/Organization/Home/Home.tsx
+++ b/apps/web/screens/Organization/Home/Home.tsx
@@ -1,6 +1,7 @@
/* eslint-disable jsx-a11y/anchor-is-valid */
import { useMemo } from 'react'
import NextLink from 'next/link'
+import { useRouter } from 'next/router'
import {
Box,
@@ -57,16 +58,21 @@ const OrganizationHomePage: Screen = ({
const n = useNamespace(namespace)
useContentfulId(organizationPage?.id)
const { linkResolver } = useLinkResolver()
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore make web strict
+ const router = useRouter()
+
+ const pathWithoutHash = router.asPath.split('#')[0]
+
const navList: NavigationItem[] =
organizationPage?.menuLinks.map(({ primaryLink, childrenLinks }) => ({
- title: primaryLink?.text,
- href: primaryLink?.url,
- active: false,
+ title: primaryLink?.text ?? '',
+ href: primaryLink?.url ?? '',
+ active:
+ primaryLink?.url === pathWithoutHash ||
+ childrenLinks.some((link) => link.url === pathWithoutHash),
items: childrenLinks.map(({ text, url }) => ({
title: text,
href: url,
+ active: url === pathWithoutHash,
})),
})) ?? []
diff --git a/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculator.tsx b/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculator.tsx
index cc9eacdacace..2b0fee8a1854 100644
--- a/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculator.tsx
+++ b/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculator.tsx
@@ -59,8 +59,6 @@ import {
} from './utils'
import * as styles from './PensionCalculator.css'
-const CURRENCY_INPUT_MAX_LENGTH = 15
-
const lowercaseFirstLetter = (value: string | undefined) => {
if (!value) return value
return value[0].toLowerCase() + value.slice(1)
@@ -132,6 +130,9 @@ const PensionCalculator: CustomScreen = ({
defaultValues,
})
+ const currencyInputMaxLength =
+ customPageData?.configJson?.currencyInputMaxLength ?? 14
+
const maxMonthPensionDelay =
customPageData?.configJson?.maxMonthPensionDelay ?? 156
@@ -940,7 +941,7 @@ const PensionCalculator: CustomScreen = ({
)}
placeholder="kr."
currency={true}
- maxLength={CURRENCY_INPUT_MAX_LENGTH}
+ maxLength={currencyInputMaxLength}
/>
@@ -962,7 +963,7 @@ const PensionCalculator: CustomScreen = ({
)}
placeholder="kr."
currency={true}
- maxLength={CURRENCY_INPUT_MAX_LENGTH}
+ maxLength={currencyInputMaxLength}
/>
@@ -988,7 +989,7 @@ const PensionCalculator: CustomScreen = ({
)}
placeholder="kr."
currency={true}
- maxLength={CURRENCY_INPUT_MAX_LENGTH}
+ maxLength={currencyInputMaxLength}
/>
@@ -1010,7 +1011,7 @@ const PensionCalculator: CustomScreen = ({
)}
placeholder="kr."
currency={true}
- maxLength={CURRENCY_INPUT_MAX_LENGTH}
+ maxLength={currencyInputMaxLength}
/>
@@ -1032,7 +1033,7 @@ const PensionCalculator: CustomScreen = ({
)}
placeholder="kr."
currency={true}
- maxLength={CURRENCY_INPUT_MAX_LENGTH}
+ maxLength={currencyInputMaxLength}
/>
@@ -1058,7 +1059,7 @@ const PensionCalculator: CustomScreen = ({
)}
placeholder="kr."
currency={true}
- maxLength={CURRENCY_INPUT_MAX_LENGTH}
+ maxLength={currencyInputMaxLength}
/>
@@ -1084,7 +1085,7 @@ const PensionCalculator: CustomScreen = ({
)}
placeholder="kr."
currency={true}
- maxLength={CURRENCY_INPUT_MAX_LENGTH}
+ maxLength={currencyInputMaxLength}
/>
@@ -1106,7 +1107,7 @@ const PensionCalculator: CustomScreen = ({
)}
placeholder="kr."
currency={true}
- maxLength={CURRENCY_INPUT_MAX_LENGTH}
+ maxLength={currencyInputMaxLength}
/>
diff --git a/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculatorResults.css.ts b/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculatorResults.css.ts
index adb9ce580366..54f76913b245 100644
--- a/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculatorResults.css.ts
+++ b/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculatorResults.css.ts
@@ -33,3 +33,14 @@ export const fitContent = style({
export const alignSelfToFlexEnd = style({
alignSelf: 'flex-end',
})
+
+export const hiddenOnScreen = style({
+ '@media': {
+ screen: {
+ display: 'none',
+ },
+ print: {
+ display: 'block',
+ },
+ },
+})
diff --git a/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculatorResults.tsx b/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculatorResults.tsx
index af4bf0bc1d7b..de4a3a81ddd2 100644
--- a/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculatorResults.tsx
+++ b/apps/web/screens/Organization/SocialInsuranceAdministration/PensionCalculatorResults.tsx
@@ -1,4 +1,4 @@
-import { Fragment } from 'react'
+import { Fragment, ReactNode } from 'react'
import { useIntl } from 'react-intl'
import {
@@ -6,6 +6,7 @@ import {
AccordionItem,
AlertMessage,
Box,
+ BoxProps,
Button,
GridColumn,
GridContainer,
@@ -21,6 +22,7 @@ import {
} from '@island.is/island-ui/core'
import { getThemeConfig } from '@island.is/web/components'
import {
+ CustomPage,
CustomPageUniqueIdentifier as UniqueIdentifier,
Organization,
OrganizationPage,
@@ -30,6 +32,8 @@ import {
QueryGetPensionCalculationArgs,
SocialInsurancePensionCalculationInput,
SocialInsurancePensionCalculationResponse,
+ SocialInsurancePensionCalculationResponseItem,
+ SocialInsurancePensionCalculationResponseItemGroup,
} from '@island.is/web/graphql/schema'
import { useLinkResolver } from '@island.is/web/hooks'
import { withMainLayout } from '@island.is/web/layouts/main'
@@ -55,6 +59,180 @@ import {
} from './utils'
import * as styles from './PensionCalculatorResults.css'
+interface HighlightedItemsProps {
+ highlightedItems: SocialInsurancePensionCalculationResponseItem[]
+ customPageData: CustomPage | null | undefined
+ firstItemRightAlignedContent?: ReactNode
+}
+
+const HighlightedItems = ({
+ highlightedItems,
+ customPageData,
+ firstItemRightAlignedContent,
+}: HighlightedItemsProps) => {
+ const { formatMessage } = useIntl()
+
+ const higlightedItemIsPresent = highlightedItems.length > 0
+ const perMonthText = formatMessage(translationStrings.perMonth)
+ const perYearText = formatMessage(translationStrings.perYear)
+
+ return (
+
+ {highlightedItems.map((highlightedItem, index) => {
+ let highlightedItemName =
+ highlightedItem?.name && highlightedItem?.name in translationStrings
+ ? formatMessage(
+ translationStrings[
+ highlightedItem?.name as keyof typeof translationStrings
+ ],
+ )
+ : highlightedItem?.name
+
+ if (
+ index > 0 &&
+ highlightedItem?.name ===
+ (customPageData?.configJson?.totalFromTrAfterTaxKey ??
+ 'REIKNH.SAMTALSTREFTIRSK')
+ ) {
+ highlightedItemName = formatMessage(
+ translationStrings.highlighedResultItemHeadingForTotalAfterTaxFromTR,
+ )
+ }
+
+ const titleVariant: TextProps['variant'] = index === 0 ? 'h3' : 'h4'
+
+ const numericVariant: TextProps['variant'] =
+ index === 0 ? 'h5' : 'medium'
+
+ return (
+
+
+ {higlightedItemIsPresent && (
+
+ {highlightedItemName}
+
+ )}
+ {!higlightedItemIsPresent && }
+ {index === 0 && firstItemRightAlignedContent}
+
+
+
+
+
+
+
+ {formatCurrency(highlightedItem?.monthlyAmount)}
+
+
+
+ {perMonthText}
+
+
+
+
+
+
+
+
+
+
+ {formatCurrency(highlightedItem?.yearlyAmount)}
+
+
+
+ {perYearText}
+
+
+
+
+
+ )
+ })}
+
+ )
+}
+
+interface ResultTableProps {
+ groups?: SocialInsurancePensionCalculationResponseItemGroup[] | null
+ /** Set this to true for less padding in the table cells */
+ dense?: boolean
+}
+
+const ResultTable = ({ groups, dense = false }: ResultTableProps) => {
+ const { formatMessage } = useIntl()
+ const perMonthText = formatMessage(translationStrings.perMonth)
+ const perYearText = formatMessage(translationStrings.perYear)
+
+ const cellProps: BoxProps | undefined = dense
+ ? { paddingBottom: 0, paddingTop: 0 }
+ : undefined
+
+ return (
+
+ {groups?.map((group, groupIndex) => (
+
+
+ {group.name && (
+
+
+ {group.name in translationStrings
+ ? formatMessage(
+ translationStrings[
+ group.name as keyof typeof translationStrings
+ ],
+ )
+ : group.name}
+
+ {perMonthText}
+ {perYearText}
+
+ )}
+ {group.items.map((item, itemIndex) => {
+ const isLastItem = itemIndex === group.items.length - 1
+ const fontWeight = isLastItem ? 'semiBold' : undefined
+ let itemName = item?.name
+ if (itemName && itemName in translationStrings) {
+ itemName = formatMessage(
+ translationStrings[
+ itemName as keyof typeof translationStrings
+ ],
+ )
+ }
+ return (
+
+
+ {itemName}
+
+
+
+ {formatCurrency(item.monthlyAmount)}
+
+
+
+
+ {formatCurrency(item.yearlyAmount)}
+
+
+
+ )
+ })}
+
+
+ ))}
+
+ )
+}
+
interface PensionCalculatorResultsProps {
organizationPage: OrganizationPage
organization: Organization
@@ -78,8 +256,6 @@ const PensionCalculatorResults: CustomScreen = ({
const highlightedItems = calculation.highlightedItems ?? []
- const perMonthText = formatMessage(translationStrings.perMonth)
- const perYearText = formatMessage(translationStrings.perYear)
const title = `${formatMessage(translationStrings.mainTitle)} ${
dateOfCalculationsOptions.find(
(o) => o.value === calculationInput.dateOfCalculations,
@@ -93,275 +269,151 @@ const PensionCalculatorResults: CustomScreen = ({
const higlightedItemIsPresent = highlightedItems.length > 0
return (
-
-
-
-
-
-
-
-
- {title}
-
-
-
- {formatMessage(translationStrings.resultDisclaimer)}
-
-
-
- {higlightedItemIsPresent && (
+ <>
+
+
+
+
+
+
- {highlightedItems.map((highlightedItem, index) => {
- let highlightedItemName =
- highlightedItem?.name &&
- highlightedItem?.name in translationStrings
- ? formatMessage(
- translationStrings[
- highlightedItem?.name as keyof typeof translationStrings
- ],
- )
- : highlightedItem?.name
-
- if (
- index > 0 &&
- highlightedItem?.name ===
- (customPageData?.configJson?.totalFromTrAfterTaxKey ??
- 'REIKNH.SAMTALSTREFTIRSK')
- ) {
- highlightedItemName = formatMessage(
- translationStrings.highlighedResultItemHeadingForTotalAfterTaxFromTR,
- )
- }
-
- const titleVariant: TextProps['variant'] =
- index === 0 ? 'h3' : 'h4'
-
- const numericVariant: TextProps['variant'] =
- index === 0 ? 'h5' : 'medium'
-
- return (
-
-
- {higlightedItemIsPresent && (
-
- {highlightedItemName}
-
- )}
- {!higlightedItemIsPresent && }
- {index === 0 && (
-
-