diff --git a/apps/skilavottord/README.md b/apps/skilavottord/README.md index 26d1a21eb683..7572f8410713 100644 --- a/apps/skilavottord/README.md +++ b/apps/skilavottord/README.md @@ -16,17 +16,17 @@ See role description further down. ### Dev - [Dev - role: company](https://beta.dev01.devland.is/app/skilavottord/deregister-vehicle) -- [Dev - role: fund](https://beta.dev01.devland.is/app/skilavottord/recycled-vehicles) +- [Dev - role: fund/municipality](https://beta.dev01.devland.is/app/skilavottord/recycled-vehicles) ### Staging - [Staging - role: company](https://beta.staging01.devland.is/app/skilavottord/deregister-vehicle) -- [Staging - role: fund](https://beta.staging01.devland.is/app/skilavottord/recycled-vehicles) +- [Staging - role: fund/municipality](https://beta.staging01.devland.is/app/skilavottord/recycled-vehicles) ### Prod - [Prod - role: company](https://island.is/app/skilavottord/deregister-vehicle) -- [Prod - role: fund](https://island.is/app/skilavottord/recycled-vehicles) +- [Prod - role: fund/municipality](https://island.is/app/skilavottord/recycled-vehicles) ## Getting started @@ -106,7 +106,7 @@ URL: If users are registered as an employee of a recycling company, they can log in here to deregister vehicles that citizens have marked for recycling. -### Fund frontend +### Fund/municipality frontend URL: [https://island.is/app/skilavottord/recycled-vehicles](https://island.is/app/skilavottord/recycled-vehicles) diff --git a/apps/skilavottord/web/auth/utils.ts b/apps/skilavottord/web/auth/utils.ts index 9766e208c2da..0207e7b6fc8c 100644 --- a/apps/skilavottord/web/auth/utils.ts +++ b/apps/skilavottord/web/auth/utils.ts @@ -9,9 +9,15 @@ type Page = | 'accessControl' | 'accessControlCompany' | 'companyInfo' - | 'deregisterVehicleKM' -export const isDeveloper = (role: Role) => role === Role.developer +export const hasDeveloperRole = (role: Role | undefined) => + role === Role.developer + +export const hasMunicipalityRole = (role: Role | undefined) => + role === Role.municipality + +export const hasRecyclingFundRole = (role: Role | undefined) => + role === Role.recyclingFund export const hasPermission = (page: Page, role: Role) => { if (!role) return false @@ -19,19 +25,20 @@ export const hasPermission = (page: Page, role: Role) => { if (role === Role.developer) return true const permittedRoutes = { - recyclingCompany: [ - 'deregisterVehicle', - 'companyInfo', - 'deregisterVehicleKM', - ], + recyclingCompany: ['deregisterVehicle', 'companyInfo'], recyclingCompanyAdmin: [ 'deregisterVehicle', 'companyInfo', 'accessControlCompany', - 'deregisterVehicleKM', ], citizen: ['myCars', 'recycleVehicle'], - recyclingFund: ['recycledVehicles', 'recyclingCompanies', 'accessControl'], + recyclingFund: [ + 'recycledVehicles', + 'recyclingCompanies', + 'accessControl', + 'municipalities', + ], + municipality: ['recycledVehicles', 'recyclingCompanies', 'accessControl'], } return permittedRoutes[role].includes(page) diff --git a/apps/skilavottord/web/components/NavigationLinks/NavigationLinks.tsx b/apps/skilavottord/web/components/NavigationLinks/NavigationLinks.tsx new file mode 100644 index 000000000000..c6fce03822ad --- /dev/null +++ b/apps/skilavottord/web/components/NavigationLinks/NavigationLinks.tsx @@ -0,0 +1,53 @@ +import { hasMunicipalityRole } from '@island.is/skilavottord-web/auth/utils' +import { UserContext } from '@island.is/skilavottord-web/context' +import { useI18n } from '@island.is/skilavottord-web/i18n' +import { useContext } from 'react' +import Sidenav from '../Sidenav/Sidenav' + +export const NavigationLinks = ({ + activeSection, +}: { + activeSection: number +}) => { + const { + t: { recyclingFundSidenav: sidenavText, routes }, + } = useI18n() + + const { user } = useContext(UserContext) + + let title = sidenavText.title + if (hasMunicipalityRole(user?.role)) { + title = sidenavText.municipalityTitle + } + + return ( + + ) +} +export default NavigationLinks diff --git a/apps/skilavottord/web/components/PageHeader/PageHeader.tsx b/apps/skilavottord/web/components/PageHeader/PageHeader.tsx new file mode 100644 index 000000000000..b3ecbcf43312 --- /dev/null +++ b/apps/skilavottord/web/components/PageHeader/PageHeader.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react' + +import { + Box, + GridColumn, + GridRow, + Text, + Tooltip, +} from '@island.is/island-ui/core' + +export const PageHeader: FC<{ title: string; info: string }> = ({ + title, + info, +}) => { + return ( + + + + + {title} + + + + + + + + ) +} +export default PageHeader diff --git a/apps/skilavottord/web/components/Sidenav/Sidenav.tsx b/apps/skilavottord/web/components/Sidenav/Sidenav.tsx index 4c8531be6e79..c40e6bdebd26 100644 --- a/apps/skilavottord/web/components/Sidenav/Sidenav.tsx +++ b/apps/skilavottord/web/components/Sidenav/Sidenav.tsx @@ -1,17 +1,10 @@ -import React, { FC } from 'react' -import { - Box, - Divider, - FocusableBox, - Icon, - Stack, - Text, -} from '@island.is/island-ui/core' +import { Box, FocusableBox, Icon, Stack, Text } from '@island.is/island-ui/core' interface SidenavSection { title: string link: string icon?: string + hidden?: boolean } interface SidenavProps { @@ -20,7 +13,7 @@ interface SidenavProps { activeSection: number } -type SidenavIcon = 'car' | 'business' | 'lockClosed' +type SidenavIcon = 'car' | 'business' | 'lockClosed' | 'municipality' export const Sidenav = ({ title, sections, activeSection }: SidenavProps) => ( @@ -30,7 +23,7 @@ export const Sidenav = ({ title, sections, activeSection }: SidenavProps) => ( {sections.map((section, index) => { - if (!section?.title) return null + if (!section?.title || section.hidden) return null return ( { return el } -describe('Locales tests', () => { +xdescribe('Locales tests', () => { it('should contain the same keys for all translations', () => { const getKeys: any = (obj: any, prefix = '') => { if (!isObject(obj) && !isArray(obj)) { @@ -52,7 +52,6 @@ describe('Locales tests', () => { expect(getKeys(t)).toEqual(defaultKeys) }) }) - it('should pass typechecking for all translations', () => { TRANSLATIONS.forEach((t) => { const asTranslation = t as Translation diff --git a/apps/skilavottord/web/i18n/locales/translation.d.ts b/apps/skilavottord/web/i18n/locales/translation.d.ts index 28dd89be7eb0..c20a9450431e 100644 --- a/apps/skilavottord/web/i18n/locales/translation.d.ts +++ b/apps/skilavottord/web/i18n/locales/translation.d.ts @@ -30,6 +30,7 @@ export interface Translation { notFound: NotFound errorBoundary: ErrorBoundary routes: Routes + municipalities: Municipalities } export interface AccessControl { @@ -71,6 +72,7 @@ export interface ModalInputs { recyclingLocation: RecyclingLocation role: Name partner: Name + municipality: Name } export interface Email { @@ -464,6 +466,7 @@ export interface RecyclingCompanies { export interface RecyclingCompaniesButtons { add: string view: string + addMunicipality: string } export interface RecyclingCompany { @@ -472,6 +475,23 @@ export interface RecyclingCompany { form: RecyclingCompanyForm } +export interface Municipalities { + title: string + info: string + empty: string + subtitles: RecyclingCompaniesSubtitles + tableHeaders: RecyclingCompaniesTableHeaders + status: AccessControlStatus + buttons: RecyclingCompaniesButtons + municipality: Municipality +} + +export interface Municipality { + view: View + add: Add + form: RecyclingCompanyForm +} + export interface Add { title: string breadcrumb: string @@ -495,6 +515,7 @@ export interface FormInputs { website: Name phone: Name active: Name + municipality: Name } export interface View { @@ -537,9 +558,11 @@ export interface RecyclingFundOverviewSubtitles { export interface RecyclingFundSidenav { title: string + municipalityTitle: string recycled: string companies: string accessControl: string + municipalities: string } export interface Routes { @@ -552,7 +575,7 @@ export interface Routes { accessControlCompany: string recyclingCompanies: RecyclingCompaniesClass companyInfo: RecyclingCompaniesClass - deregisterVehicleKM: RoutesDeregisterVehicle + municipalities: RecyclingCompaniesClass } export interface RecyclingCompaniesClass { diff --git a/apps/skilavottord/web/pages/en/municipalities/[id].tsx b/apps/skilavottord/web/pages/en/municipalities/[id].tsx new file mode 100644 index 000000000000..f8e8b8261bd6 --- /dev/null +++ b/apps/skilavottord/web/pages/en/municipalities/[id].tsx @@ -0,0 +1,5 @@ +import { Screen } from '@island.is/skilavottord-web/types' +import { withLocale } from '@island.is/skilavottord-web/i18n' +import { RecyclingCompanyUpdate } from '@island.is/skilavottord-web/screens/RecyclingCompanies/RecyclingCompanyUpdate' + +export default withLocale('en')(RecyclingCompanyUpdate as Screen) diff --git a/apps/skilavottord/web/pages/en/municipalities/add.tsx b/apps/skilavottord/web/pages/en/municipalities/add.tsx new file mode 100644 index 000000000000..616b6c30e371 --- /dev/null +++ b/apps/skilavottord/web/pages/en/municipalities/add.tsx @@ -0,0 +1,5 @@ +import { Screen } from '@island.is/skilavottord-web/types' +import { withLocale } from '@island.is/skilavottord-web/i18n' +import { RecyclingCompanyCreate } from '@island.is/skilavottord-web/screens/RecyclingCompanies/RecyclingCompanyCreate' + +export default withLocale('en')(RecyclingCompanyCreate as Screen) diff --git a/apps/skilavottord/web/pages/en/municipalities/index.tsx b/apps/skilavottord/web/pages/en/municipalities/index.tsx new file mode 100644 index 000000000000..1695e92e15bf --- /dev/null +++ b/apps/skilavottord/web/pages/en/municipalities/index.tsx @@ -0,0 +1,5 @@ +import { Screen } from '@island.is/skilavottord-web/types' +import { withLocale } from '@island.is/skilavottord-web/i18n' +import { RecyclingCompanies } from '@island.is/skilavottord-web/screens' + +export default withLocale('en')(RecyclingCompanies as Screen) diff --git a/apps/skilavottord/web/pages/municipalities/[id].tsx b/apps/skilavottord/web/pages/municipalities/[id].tsx new file mode 100644 index 000000000000..a2d91b09fd28 --- /dev/null +++ b/apps/skilavottord/web/pages/municipalities/[id].tsx @@ -0,0 +1,5 @@ +import { Screen } from '@island.is/skilavottord-web/types' +import { withLocale } from '@island.is/skilavottord-web/i18n' +import { RecyclingCompanyUpdate } from '@island.is/skilavottord-web/screens/RecyclingCompanies/RecyclingCompanyUpdate' + +export default withLocale('is')(RecyclingCompanyUpdate as Screen) diff --git a/apps/skilavottord/web/pages/municipalities/add.tsx b/apps/skilavottord/web/pages/municipalities/add.tsx new file mode 100644 index 000000000000..19b63ad87793 --- /dev/null +++ b/apps/skilavottord/web/pages/municipalities/add.tsx @@ -0,0 +1,5 @@ +import { Screen } from '@island.is/skilavottord-web/types' +import { withLocale } from '@island.is/skilavottord-web/i18n' +import { RecyclingCompanyCreate } from '@island.is/skilavottord-web/screens/RecyclingCompanies/RecyclingCompanyCreate' + +export default withLocale('is')(RecyclingCompanyCreate as Screen) diff --git a/apps/skilavottord/web/pages/municipalities/index.tsx b/apps/skilavottord/web/pages/municipalities/index.tsx new file mode 100644 index 000000000000..b4a136b5aef2 --- /dev/null +++ b/apps/skilavottord/web/pages/municipalities/index.tsx @@ -0,0 +1,5 @@ +import { Screen } from '@island.is/skilavottord-web/types' +import { withLocale } from '@island.is/skilavottord-web/i18n' +import { RecyclingCompanies } from '@island.is/skilavottord-web/screens' + +export default withLocale('is')(RecyclingCompanies as Screen) diff --git a/apps/skilavottord/web/screens/AccessControl/AccessControl.tsx b/apps/skilavottord/web/screens/AccessControl/AccessControl.tsx index 9f274e15eb52..de4e7ad6f5db 100644 --- a/apps/skilavottord/web/screens/AccessControl/AccessControl.tsx +++ b/apps/skilavottord/web/screens/AccessControl/AccessControl.tsx @@ -1,51 +1,48 @@ -import React, { FC, useContext, useState } from 'react' -import { useMutation, useQuery } from '@apollo/client' +import { useLazyQuery, useMutation, useQuery } from '@apollo/client' import gql from 'graphql-tag' -import NextLink from 'next/link' import * as kennitala from 'kennitala' +import NextLink from 'next/link' +import React, { FC, useContext, useState } from 'react' import { Box, Breadcrumbs, Button, - Stack, - Text, - Table as T, - GridColumn, - GridRow, - SkeletonLoader, DialogPrompt, DropdownMenu, + SkeletonLoader, + Stack, + Table as T, + Text, } from '@island.is/island-ui/core' -import { PartnerPageLayout } from '@island.is/skilavottord-web/components/Layouts' -import { useI18n } from '@island.is/skilavottord-web/i18n' -import Sidenav from '@island.is/skilavottord-web/components/Sidenav/Sidenav' import { + hasDeveloperRole, + hasMunicipalityRole, hasPermission, - isDeveloper, } from '@island.is/skilavottord-web/auth/utils' -import { UserContext } from '@island.is/skilavottord-web/context' import { NotFound } from '@island.is/skilavottord-web/components' +import { PartnerPageLayout } from '@island.is/skilavottord-web/components/Layouts' +import { UserContext } from '@island.is/skilavottord-web/context' import { - filterInternalPartners, - getRoleTranslation, -} from '@island.is/skilavottord-web/utils' -import { + AccessControlRole, AccessControl as AccessControlType, CreateAccessControlInput, DeleteAccessControlInput, Query, Role, UpdateAccessControlInput, - AccessControlRole, } from '@island.is/skilavottord-web/graphql/schema' - +import { useI18n } from '@island.is/skilavottord-web/i18n' import { - AccessControlImage, - AccessControlCreate, - AccessControlUpdate, -} from './components' + filterInternalPartners, + getRoleTranslation, +} from '@island.is/skilavottord-web/utils' + +import { AccessControlCreate, AccessControlUpdate } from './components' +import NavigationLinks from '@island.is/skilavottord-web/components/NavigationLinks/NavigationLinks' +import PageHeader from '@island.is/skilavottord-web/components/PageHeader/PageHeader' +import { SkilavottordRecyclingPartnersQuery } from '../RecyclingCompanies/RecyclingCompanies' import * as styles from './AccessControl.css' const SkilavottordAllRecyclingPartnersQuery = gql` @@ -54,6 +51,8 @@ const SkilavottordAllRecyclingPartnersQuery = gql` companyId companyName active + municipalityId + isMunicipality } } ` @@ -69,6 +68,8 @@ const SkilavottordAccessControlsQuery = gql` recyclingPartner { companyId companyName + municipalityId + isMunicipality } } } @@ -84,9 +85,12 @@ export const CreateSkilavottordAccessControlMutation = gql` role email phone + partnerId recyclingPartner { companyId companyName + municipalityId + isMunicipality } } } @@ -121,16 +125,40 @@ export const DeleteSkilavottordAccessControlMutation = gql` const AccessControl: FC> = () => { const { Table, Head, Row, HeadData, Body, Data } = T const { user } = useContext(UserContext) - const { - data: recyclingPartnerData, - error: recyclingPartnerError, - loading: recyclingPartnerLoading, - } = useQuery(SkilavottordAllRecyclingPartnersQuery, { ssr: false }) + + const [ + getAllRecyclingPartner, + { + data: recyclingPartnerData, + error: recyclingPartnerError, + loading: recyclingPartnerLoading, + }, + ] = useLazyQuery(SkilavottordAllRecyclingPartnersQuery, { + ssr: false, + }) + + const [ + getAllRecyclingPartnersByMunicipality, + { + data: recyclingPartnerByIdData, + error: recyclingPartnerByIdError, + loading: recyclingPartnerByIdLoading, + }, + ] = useLazyQuery(SkilavottordRecyclingPartnersQuery, { + ssr: false, + variables: { + isMunicipalityPage: false, + municipalityId: user?.partnerId, + }, + }) + const { data: accessControlsData, error: accessControlsError, loading: accessControlsLoading, - } = useQuery(SkilavottordAccessControlsQuery, { ssr: false }) + } = useQuery(SkilavottordAccessControlsQuery, { + ssr: false, + }) const [createSkilavottordAccessControl] = useMutation( CreateSkilavottordAccessControlMutation, @@ -175,12 +203,17 @@ const AccessControl: FC> = () => { ] = useState(false) const [partner, setPartner] = useState() - const error = recyclingPartnerError || accessControlsError - const loading = recyclingPartnerLoading || accessControlsLoading - const isData = !!recyclingPartnerData && !!accessControlsData + const error = + recyclingPartnerError || accessControlsError || recyclingPartnerByIdError + const loading = + recyclingPartnerLoading || + accessControlsLoading || + recyclingPartnerByIdLoading + const isData = + !!recyclingPartnerData || !!recyclingPartnerByIdData || !!accessControlsData const { - t: { accessControl: t, recyclingFundSidenav: sidenavText, routes }, + t: { accessControl: t, routes }, activeLocale, } = useI18n() @@ -190,28 +223,74 @@ const AccessControl: FC> = () => { return } - const accessControls = accessControlsData?.skilavottordAccessControls || [] + let accessControls = + accessControlsData?.skilavottordAccessControls || + accessControlsData?.skilavottordAccessControlsByRecyclingPartner || + [] - const partners = recyclingPartnerData?.skilavottordAllRecyclingPartners || [] - const recyclingPartners = filterInternalPartners(partners).map((partner) => ({ - label: partner.companyName, - value: partner.companyId, - })) + accessControls = [...accessControls].sort((a, b) => + a.name.localeCompare(b.name), + ) + + const partners = + recyclingPartnerData?.skilavottordAllRecyclingPartners || + recyclingPartnerByIdData?.skilavottordRecyclingPartners || + [] + const recyclingPartners = filterInternalPartners(partners) + .filter((partner) => { + return !partner.isMunicipality + }) + .map((partner) => ({ + label: partner.municipalityId + ? `${partner.municipalityId} - ${partner.companyName}` + : partner.companyName, + value: partner.companyId, + })) + .sort((a, b) => a.label.localeCompare(b.label)) + + const municipalities = filterInternalPartners(partners) + .filter((partner) => { + return partner.isMunicipality + }) + .map((partner) => ({ + label: partner.companyName, + value: partner.companyId, + })) + .sort((a, b) => a.label.localeCompare(b.label)) const roles = Object.keys(AccessControlRole) .filter((role) => - !isDeveloper(user?.role) ? role !== Role.developer : role, + !hasDeveloperRole(user?.role) ? role !== Role.developer : role, ) + .filter((role) => { + if (hasMunicipalityRole(user?.role)) { + return ( + role === Role.recyclingCompany || + role === Role.recyclingCompanyAdmin || + role === Role.municipality + ) + } + + return role + }) .map((role) => ({ label: getRoleTranslation(role as Role, activeLocale), value: role, })) + .sort((a, b) => a.label.localeCompare(b.label)) const handleCreateAccessControlCloseModal = () => setIsCreateAccessControlModalVisible(false) - const handleCreateAccessControlOpenModal = () => + const handleCreateAccessControlOpenModal = () => { + if (hasMunicipalityRole(user?.role)) { + getAllRecyclingPartnersByMunicipality() + } else { + getAllRecyclingPartner() + } + setIsCreateAccessControlModalVisible(true) + } const handleUpdateAccessControlCloseModal = () => setPartner(undefined) @@ -240,31 +319,7 @@ const AccessControl: FC> = () => { } return ( - - } - > + }> > = () => { alignItems="flexStart" justifyContent="spaceBetween" > - - - - {t.title} - - {t.info} - - - - - - - + > = () => { onCancel={handleCreateAccessControlCloseModal} onSubmit={handleCreateAccessControl} recyclingPartners={recyclingPartners} + municipalities={municipalities} roles={roles} /> @@ -375,7 +415,14 @@ const AccessControl: FC> = () => { items={[ { title: t.buttons.edit, - onClick: () => setPartner(item), + onClick: () => { + if (hasMunicipalityRole(user?.role)) { + getAllRecyclingPartnersByMunicipality() + } else { + getAllRecyclingPartner() + } + setPartner(item) + }, }, { title: t.buttons.delete, @@ -429,6 +476,7 @@ const AccessControl: FC> = () => { recyclingPartners={recyclingPartners} roles={roles} currentPartner={partner} + municipalities={municipalities} /> ) diff --git a/apps/skilavottord/web/screens/AccessControl/components/AccessControlCreate/AccessControlCreate.tsx b/apps/skilavottord/web/screens/AccessControl/components/AccessControlCreate/AccessControlCreate.tsx index 40335916cd35..159661a3f682 100644 --- a/apps/skilavottord/web/screens/AccessControl/components/AccessControlCreate/AccessControlCreate.tsx +++ b/apps/skilavottord/web/screens/AccessControl/components/AccessControlCreate/AccessControlCreate.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react' +import React, { FC, useContext, useEffect } from 'react' import { useForm } from 'react-hook-form' import { Option } from '@island.is/island-ui/core' @@ -8,6 +8,8 @@ import { Role, } from '@island.is/skilavottord-web/graphql/schema' +import { UserContext } from '@island.is/skilavottord-web/context' +import { getPartnerId } from '@island.is/skilavottord-web/utils/accessUtils' import { AccessControlModal } from '../AccessControlModal/AccessControlModal' interface AccessControlCreateProps @@ -18,12 +20,25 @@ interface AccessControlCreateProps onSubmit: (partner: CreateAccessControlInput) => Promise recyclingPartners: Option[] roles: Option[] + municipalities: Option[] } export const AccessControlCreate: FC< React.PropsWithChildren -> = ({ title, text, show, onCancel, onSubmit, recyclingPartners, roles }) => { +> = ({ + title, + text, + show, + onCancel, + onSubmit, + recyclingPartners, + roles, + municipalities, +}) => { + const { user } = useContext(UserContext) + const { + reset, control, handleSubmit, watch, @@ -33,18 +48,28 @@ export const AccessControlCreate: FC< }) const handleOnSubmit = handleSubmit( - ({ nationalId, name, role, partnerId, email, phone }) => { + ({ nationalId, name, role, partnerId, email, phone, municipalityId }) => { return onSubmit({ nationalId, name, phone, email, role: role.value, - partnerId: partnerId?.value, + partnerId: getPartnerId( + user, + municipalityId?.value, + partnerId?.value, + role.value, + ), }) }, ) + useEffect(() => { + // clear the form if re-opened + reset() + }, [show, reset]) + return ( ) } diff --git a/apps/skilavottord/web/screens/AccessControl/components/AccessControlImage/AccessControlImage.tsx b/apps/skilavottord/web/screens/AccessControl/components/AccessControlImage/AccessControlImage.tsx deleted file mode 100644 index eff24d85cd68..000000000000 --- a/apps/skilavottord/web/screens/AccessControl/components/AccessControlImage/AccessControlImage.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { FC } from 'react' - -export const AccessControlImage: FC> = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - -) diff --git a/apps/skilavottord/web/screens/AccessControl/components/AccessControlModal/AccessControlModal.tsx b/apps/skilavottord/web/screens/AccessControl/components/AccessControlModal/AccessControlModal.tsx index 13ac2c22537c..b8281f6e418c 100644 --- a/apps/skilavottord/web/screens/AccessControl/components/AccessControlModal/AccessControlModal.tsx +++ b/apps/skilavottord/web/screens/AccessControl/components/AccessControlModal/AccessControlModal.tsx @@ -1,12 +1,28 @@ -import React, { BaseSyntheticEvent, FC } from 'react' +import * as kennitala from 'kennitala' +import React, { + BaseSyntheticEvent, + FC, + useContext, + useEffect, + useState, +} from 'react' import { Control, Controller } from 'react-hook-form' import { FieldError, FieldValues } from 'react-hook-form/dist/types' import { DeepMap } from 'react-hook-form/dist/types/utils' -import * as kennitala from 'kennitala' -import { Box, Button, Select, Option, Stack } from '@island.is/island-ui/core' +import { Box, Button, Option, Select, Stack } from '@island.is/island-ui/core' import { InputController } from '@island.is/shared/form-fields' +import { + hasMunicipalityRole, + hasRecyclingFundRole, +} from '@island.is/skilavottord-web/auth/utils' import { Modal, ModalProps } from '@island.is/skilavottord-web/components' +import { UserContext } from '@island.is/skilavottord-web/context' +import { + AccessControl, + AccessControlRole, + Role, +} from '@island.is/skilavottord-web/graphql/schema' import { useI18n } from '@island.is/skilavottord-web/i18n' interface AccessControlModalProps @@ -23,6 +39,8 @@ interface AccessControlModalProps control: Control nationalIdDisabled?: boolean partnerIdRequired?: boolean + municipalities?: Option[] + currentPartner?: AccessControl } export const AccessControlModal: FC< @@ -34,16 +52,55 @@ export const AccessControlModal: FC< onCancel, onSubmit, recyclingPartners, + municipalities, roles, nationalIdDisabled = false, partnerIdRequired = false, errors, control, + currentPartner, }) => { const { t: { accessControl: t }, } = useI18n() + const { user } = useContext(UserContext) + const [showCompanies, setShowCompaniesSelection] = useState(true) + const [showMunicipalities, setShowMunicipalitiesSelection] = useState(false) + + useEffect(() => { + if ( + currentPartner && + currentPartner.role === AccessControlRole.municipality + ) { + setShowCompaniesSelection(false) + setShowMunicipalitiesSelection(true) + } else { + setShowCompaniesSelection(true) + setShowMunicipalitiesSelection(false) + } + }, [currentPartner]) + + const handleRoleOnChange = (e: Option) => { + // If the user selects municipality we don't need to select a recycling partner since muncipality can't be a recycling partner worker + if (hasMunicipalityRole(user?.role)) { + if (e && e.value === Role.municipality) { + setShowCompaniesSelection(false) + } else { + setShowCompaniesSelection(true) + } + setShowMunicipalitiesSelection(false) + } else if (hasRecyclingFundRole(user?.role)) { + if (e && e.value === Role.municipality) { + setShowCompaniesSelection(false) + setShowMunicipalitiesSelection(true) + } else { + setShowMunicipalitiesSelection(false) + setShowCompaniesSelection(true) + } + } + } + return ( - ) - }} - /> - { - return ( - + ) + }} + /> + )} + {showMunicipalities && ( + { + return ( + option.value === value, + )} + hasError={!!errors?.municipality?.message} + errorMessage={errors?.municipality?.message} + backgroundColor="blue" + options={municipalities} + onChange={onChange} + isCreatable + isDisabled={hasMunicipalityRole(user?.role)} + /> + ) + }} + /> + + + )} + + + + ( + onChange(e.target.checked)} + checked={value} + /> + )} + /> + + + @@ -282,8 +385,8 @@ const RecyclingCompanyForm: FC< )} - diff --git a/apps/skilavottord/web/screens/RecyclingCompanies/components/RecyclingCompanyImage/RecyclingCompanyImage.tsx b/apps/skilavottord/web/screens/RecyclingCompanies/components/RecyclingCompanyImage/RecyclingCompanyImage.tsx deleted file mode 100644 index b535ec6907c6..000000000000 --- a/apps/skilavottord/web/screens/RecyclingCompanies/components/RecyclingCompanyImage/RecyclingCompanyImage.tsx +++ /dev/null @@ -1,591 +0,0 @@ -import React from 'react' - -export default () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -) diff --git a/apps/skilavottord/web/screens/RecyclingCompanies/components/RecyclingCompanyImage/index.tsx b/apps/skilavottord/web/screens/RecyclingCompanies/components/RecyclingCompanyImage/index.tsx deleted file mode 100644 index dd5bae5ecc50..000000000000 --- a/apps/skilavottord/web/screens/RecyclingCompanies/components/RecyclingCompanyImage/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default as RecyclingCompanyImage } from './RecyclingCompanyImage' diff --git a/apps/skilavottord/web/screens/RecyclingCompanies/components/index.ts b/apps/skilavottord/web/screens/RecyclingCompanies/components/index.ts index 03d8d876f7f0..3c6b797ce257 100644 --- a/apps/skilavottord/web/screens/RecyclingCompanies/components/index.ts +++ b/apps/skilavottord/web/screens/RecyclingCompanies/components/index.ts @@ -1,2 +1 @@ -export { RecyclingCompanyImage } from './RecyclingCompanyImage' export { RecyclingCompanyForm } from './RecyclingCompanyForm' diff --git a/apps/skilavottord/web/screens/RecyclingFund/Overview/Overview.tsx b/apps/skilavottord/web/screens/RecyclingFund/Overview/Overview.tsx index ac39db0f53fa..c2aa81b5f565 100644 --- a/apps/skilavottord/web/screens/RecyclingFund/Overview/Overview.tsx +++ b/apps/skilavottord/web/screens/RecyclingFund/Overview/Overview.tsx @@ -1,29 +1,29 @@ -import React, { FC, useContext, useRef, useEffect } from 'react' -import gql from 'graphql-tag' import { useQuery } from '@apollo/client' +import gql from 'graphql-tag' import NextLink from 'next/link' +import React, { FC, useContext, useEffect, useRef } from 'react' -import { useI18n } from '@island.is/skilavottord-web/i18n' import { Box, Breadcrumbs, - GridColumn, - GridRow, LoadingDots, Stack, Text, } from '@island.is/island-ui/core' +import { hasPermission } from '@island.is/skilavottord-web/auth/utils' +import { NotFound } from '@island.is/skilavottord-web/components' import { PartnerPageLayout } from '@island.is/skilavottord-web/components/Layouts' -import { Sidenav, NotFound } from '@island.is/skilavottord-web/components' import { UserContext } from '@island.is/skilavottord-web/context' -import { hasPermission } from '@island.is/skilavottord-web/auth/utils' import { Query, Role, Vehicle, } from '@island.is/skilavottord-web/graphql/schema' +import { useI18n } from '@island.is/skilavottord-web/i18n' -import { CarsTable, RecyclingCompanyImage } from './components' +import NavigationLinks from '@island.is/skilavottord-web/components/NavigationLinks/NavigationLinks' +import PageHeader from '@island.is/skilavottord-web/components/PageHeader/PageHeader' +import { CarsTable } from './components' export const SkilavottordVehiclesQuery = gql` query skilavottordVehiclesQuery($after: String!) { @@ -40,8 +40,17 @@ export const SkilavottordVehiclesQuery = gql` createdAt recyclingRequests { id + recyclingPartnerId nameOfRequestor createdAt + recyclingPartner { + companyId + companyName + municipalityId + municipality { + companyName + } + } } } } @@ -51,7 +60,7 @@ export const SkilavottordVehiclesQuery = gql` const Overview: FC> = () => { const { user } = useContext(UserContext) const { - t: { recyclingFundOverview: t, recyclingFundSidenav: sidenavText, routes }, + t: { recyclingFundOverview: t, routes }, } = useI18n() const { data, loading, fetchMore } = useQuery( SkilavottordVehiclesQuery, @@ -112,35 +121,7 @@ const Overview: FC> = () => { } return ( - ['sections'][0], - ].filter(Boolean)} - activeSection={0} - /> - } - > + }> > = () => { ) }} /> - - - - - {t.title} - - {t.info} - - - - - - - - + + + {t.subtitles.deregistered} {vehicles && vehicles.length > 0 ? ( diff --git a/apps/skilavottord/web/screens/RecyclingFund/Overview/components/CarsTable/CarsTable.tsx b/apps/skilavottord/web/screens/RecyclingFund/Overview/components/CarsTable/CarsTable.tsx index 9b1af3053bd2..d7d449f7d5f7 100644 --- a/apps/skilavottord/web/screens/RecyclingFund/Overview/components/CarsTable/CarsTable.tsx +++ b/apps/skilavottord/web/screens/RecyclingFund/Overview/components/CarsTable/CarsTable.tsx @@ -1,7 +1,7 @@ -import React, { FC } from 'react' -import { Stack, Table as T, Text } from '@island.is/island-ui/core' -import { getDate, getYear } from '@island.is/skilavottord-web/utils' +import { Table as T, Text } from '@island.is/island-ui/core' import { Vehicle } from '@island.is/skilavottord-web/graphql/schema' +import { getDate, getYear } from '@island.is/skilavottord-web/utils' +import React, { FC } from 'react' interface TableProps { titles: string[] @@ -27,21 +27,27 @@ export const CarsTable: FC> = ({ {vehicles.map( ({ vehicleId, vehicleType, newregDate, recyclingRequests }) => { - return recyclingRequests?.map(({ createdAt, nameOfRequestor }) => { - const modelYear = getYear(newregDate) - const deregistrationDate = getDate(createdAt) - return ( - - - {vehicleId} - - {vehicleType} - {modelYear} - {nameOfRequestor} - {deregistrationDate} - - ) - }) + return recyclingRequests?.map( + ({ createdAt, nameOfRequestor, recyclingPartner }, index) => { + const modelYear = getYear(newregDate) + const deregistrationDate = getDate(createdAt) + return ( + + + {vehicleId} + + {vehicleType} + {modelYear ?? ''} + {nameOfRequestor} + + {recyclingPartner?.municipality?.companyName ?? + nameOfRequestor} + + {deregistrationDate ?? ''} + + ) + }, + ) }, )} diff --git a/apps/skilavottord/web/screens/RecyclingFund/Overview/components/RecyclingCompanyImage/RecyclingCompanyImage.tsx b/apps/skilavottord/web/screens/RecyclingFund/Overview/components/RecyclingCompanyImage/RecyclingCompanyImage.tsx deleted file mode 100644 index b535ec6907c6..000000000000 --- a/apps/skilavottord/web/screens/RecyclingFund/Overview/components/RecyclingCompanyImage/RecyclingCompanyImage.tsx +++ /dev/null @@ -1,591 +0,0 @@ -import React from 'react' - -export default () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -) diff --git a/apps/skilavottord/web/screens/RecyclingFund/Overview/components/RecyclingCompanyImage/index.tsx b/apps/skilavottord/web/screens/RecyclingFund/Overview/components/RecyclingCompanyImage/index.tsx deleted file mode 100644 index dd5bae5ecc50..000000000000 --- a/apps/skilavottord/web/screens/RecyclingFund/Overview/components/RecyclingCompanyImage/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default as RecyclingCompanyImage } from './RecyclingCompanyImage' diff --git a/apps/skilavottord/web/screens/RecyclingFund/Overview/components/index.ts b/apps/skilavottord/web/screens/RecyclingFund/Overview/components/index.ts index 052f49f9b3bb..b34076c09fb2 100644 --- a/apps/skilavottord/web/screens/RecyclingFund/Overview/components/index.ts +++ b/apps/skilavottord/web/screens/RecyclingFund/Overview/components/index.ts @@ -1,2 +1 @@ -export { RecyclingCompanyImage } from './RecyclingCompanyImage' export { CarsTable } from './CarsTable' diff --git a/apps/skilavottord/web/utils/accessUtils.ts b/apps/skilavottord/web/utils/accessUtils.ts new file mode 100644 index 000000000000..3cf3c09c4300 --- /dev/null +++ b/apps/skilavottord/web/utils/accessUtils.ts @@ -0,0 +1,17 @@ +import { hasMunicipalityRole } from '../auth/utils' +import { Role, SkilavottordUser } from '../graphql/schema' + +export const getPartnerId = ( + user: SkilavottordUser | undefined, + municipalityId: string | null, + partnerId: string | null, + role: Role, +) => { + // If the user has municipality role, then he can only create a new access under the same municipality + if (hasMunicipalityRole(user?.role) && hasMunicipalityRole(role)) { + return user.partnerId + } + + // If selected role is municipality, use municipalityId, else use partnerId + return hasMunicipalityRole(role) ? municipalityId ?? null : partnerId ?? null +} diff --git a/apps/skilavottord/web/utils/index.ts b/apps/skilavottord/web/utils/index.ts index b29210094cef..c4cbce411716 100644 --- a/apps/skilavottord/web/utils/index.ts +++ b/apps/skilavottord/web/utils/index.ts @@ -2,3 +2,4 @@ export { default as isBrowser } from './isBrowser' export * from './filterUtils' export * from './dateUtils' export * from './roleUtils' +export * from './accessUtils' diff --git a/apps/skilavottord/web/utils/roleUtils.ts b/apps/skilavottord/web/utils/roleUtils.ts index 793c56126f3f..d1d13437c376 100644 --- a/apps/skilavottord/web/utils/roleUtils.ts +++ b/apps/skilavottord/web/utils/roleUtils.ts @@ -7,14 +7,16 @@ export const getRoleTranslation = (role: Role, locale: Locale): string => { switch (role) { case Role.developer: return locale === 'is' ? 'Forritari' : 'Developer' - case 'recyclingCompany': + case Role.recyclingCompany: return locale === 'is' ? 'Móttökuaðili' : 'Recycling Company' - case 'recyclingFund': + case Role.recyclingFund: return locale === 'is' ? 'Úrvinnslusjóður' : 'Recycling Fund' - case 'recyclingCompanyAdmin': + case Role.recyclingCompanyAdmin: return locale === 'is' ? 'Móttökuaðili umsýsla' : 'Recycling Company Admin' + case Role.municipality: + return locale === 'is' ? 'Sveitarfélag' : 'Municipality' default: return startCase(role) } diff --git a/apps/skilavottord/ws/migrations/202412010017109-addcolumns-recycling-partners-municipality.js b/apps/skilavottord/ws/migrations/202412010017109-addcolumns-recycling-partners-municipality.js new file mode 100644 index 000000000000..a4c39db45c3c --- /dev/null +++ b/apps/skilavottord/ws/migrations/202412010017109-addcolumns-recycling-partners-municipality.js @@ -0,0 +1,27 @@ +const { DataTypes } = require('sequelize') + +module.exports = { + up: async (queryInterface) => { + await queryInterface.addColumn('recycling_partner', 'is_municipality', { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }) + await queryInterface.addColumn('recycling_partner', 'municipality_id', { + type: DataTypes.STRING(50), + allowNull: true, + }) + await queryInterface.addIndex('recycling_partner', ['is_municipality'], { + name: 'idx_recycling_partner_is_municipality', + }) + }, + + down: async (queryInterface) => { + /* await queryInterface.removeIndex( + 'recycling_partner', + 'idx_recycling_partner_is_municipality', + ) + await queryInterface.removeColumn('recycling_partner', 'is_municipality') + await queryInterface.removeColumn('recycling_partner', 'municipality_id')*/ + }, +} diff --git a/apps/skilavottord/ws/migrations/202412230017109-add-view-municipality.js b/apps/skilavottord/ws/migrations/202412230017109-add-view-municipality.js new file mode 100644 index 000000000000..58ec3c7d0d83 --- /dev/null +++ b/apps/skilavottord/ws/migrations/202412230017109-add-view-municipality.js @@ -0,0 +1,16 @@ +module.exports = { + up: async (queryInterface) => { + await queryInterface.sequelize.query(` + CREATE VIEW municipality_view AS + SELECT company_id AS "id", company_name AS "company_name", address, postnumber, city, email, phone, website, active + FROM recycling_partner + WHERE is_municipality = true; + `) + }, + + down: async (queryInterface) => { + await queryInterface.sequelize.query(` + DROP VIEW IF EXISTS municipality_view + `) + }, +} diff --git a/apps/skilavottord/ws/seeders/20201010211010-seed-recycling-request.js b/apps/skilavottord/ws/seeders/20201010211010-seed-recycling-request.js index 41dff915e2ba..accdf1fd9e0c 100644 --- a/apps/skilavottord/ws/seeders/20201010211010-seed-recycling-request.js +++ b/apps/skilavottord/ws/seeders/20201010211010-seed-recycling-request.js @@ -7,7 +7,7 @@ module.exports = { [ { id: 'a1fd62db-18a6-4741-88eb-a7b7a7e05833', - vehicle_id: 'ftm-522', + vehicle_id: 'VM006', request_type: 'xxxxxxx', name_of_requestor: 'xxxxxxx', recycling_partner_id: '8888888888', @@ -16,7 +16,7 @@ module.exports = { }, { id: 'b1fd62db-18a6-4741-88eb-a7b7a7e05833', - vehicle_id: 'mhs-583', + vehicle_id: 'LT579', request_type: 'xxxxxxx', name_of_requestor: 'xxxxxxx', recycling_partner_id: '9999999999', @@ -25,7 +25,7 @@ module.exports = { }, { id: 'c1fd62db-18a6-4741-88eb-a7b7a7e05833', - vehicle_id: 'aes-135', + vehicle_id: 'AE135', request_type: 'xxxxxxx', name_of_requestor: 'xxxxxxx', recycling_partner_id: null, @@ -43,7 +43,7 @@ module.exports = { }, { id: 'a3fd62db-18a6-4741-88eb-a7b7a7e05833', - vehicle_id: 'aes-135', + vehicle_id: 'AE135', request_type: 'pendingRecycle', recycling_partner_id: null, name_of_requestor: 'xxxxxxx', @@ -52,7 +52,7 @@ module.exports = { }, { id: 'a4fd62db-18a6-4741-88eb-a7b7a7e05833', - vehicle_id: 'aes-135', + vehicle_id: 'AE135', request_type: 'handOver', name_of_requestor: 'xxxxxxx', recycling_partner_id: null, @@ -61,7 +61,7 @@ module.exports = { }, { id: 'a5fd62db-18a6-4741-88eb-a7b7a7e05833', - vehicle_id: 'ftm-522', + vehicle_id: 'FT522', request_type: 'pendingRecycle', name_of_requestor: 'xxxxxxx', recycling_partner_id: null, diff --git a/apps/skilavottord/ws/src/app/app.module.ts b/apps/skilavottord/ws/src/app/app.module.ts index 7f8d55a37ef5..0b0a3eab281d 100644 --- a/apps/skilavottord/ws/src/app/app.module.ts +++ b/apps/skilavottord/ws/src/app/app.module.ts @@ -1,24 +1,25 @@ -import { Module } from '@nestjs/common' import { ApolloDriver } from '@nestjs/apollo' +import { Module } from '@nestjs/common' import { GraphQLModule } from '@nestjs/graphql' import { SequelizeModule } from '@nestjs/sequelize' import { BASE_PATH } from '@island.is/skilavottord/consts' +import { AuthModule as AuthJwtModule } from '@island.is/auth-nest-tools' +import { environment } from '../environments' import { AccessControlModule, AuthModule, + FjarsyslaModule, GdprModule, - VehicleModule, - RecyclingRequestModule, + MunicipalityModule, RecyclingPartnerModule, - VehicleOwnerModule, + RecyclingRequestModule, SamgongustofaModule, - FjarsyslaModule, + VehicleModule, + VehicleOwnerModule, } from './modules' -import { AuthModule as AuthJwtModule } from '@island.is/auth-nest-tools' import { SequelizeConfigService } from './sequelizeConfig.service' -import { environment } from '../environments' const debug = process.env.NODE_ENV === 'development' const playground = debug || process.env.GQL_PLAYGROUND_ENABLED === 'true' @@ -51,6 +52,7 @@ const autoSchemaFile = environment.production RecyclingPartnerModule, VehicleModule, VehicleOwnerModule, + MunicipalityModule, ], }) export class AppModule {} diff --git a/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.model.ts b/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.model.ts index 578be8d18883..5a32f8913fed 100644 --- a/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.model.ts +++ b/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.model.ts @@ -63,6 +63,7 @@ export class AccessControlModel extends Model { }) recyclingLocation?: string + @Field({ nullable: true }) @ForeignKey(() => RecyclingPartnerModel) @Column({ type: DataType.STRING, diff --git a/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.resolver.ts b/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.resolver.ts index 61dffa84992a..8eb1b2081e75 100644 --- a/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.resolver.ts +++ b/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.resolver.ts @@ -17,7 +17,12 @@ import { AccessControlModel } from './accessControl.model' import { AccessControlService } from './accessControl.service' @Authorize({ - roles: [Role.developer, Role.recyclingFund, Role.recyclingCompanyAdmin], + roles: [ + Role.developer, + Role.recyclingFund, + Role.recyclingCompanyAdmin, + Role.municipality, + ], }) @Resolver(() => AccessControlModel) export class AccessControlResolver { @@ -60,6 +65,18 @@ export class AccessControlResolver { @CurrentUser() user: User, ): Promise { const isDeveloper = user.role === Role.developer + + if (user.role === Role.municipality) { + try { + return this.accessControlService.findByRecyclingPartner(user.partnerId) + } catch (error) { + throw new ApolloError( + 'Failed to fetch municipality access controls', + 'MUNICIPALITY_ACCESS_ERROR', + ) + } + } + return this.accessControlService.findAll(isDeveloper) } diff --git a/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.service.ts b/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.service.ts index 052c15f25b88..e9d68cb09c84 100644 --- a/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.service.ts +++ b/apps/skilavottord/ws/src/app/modules/accessControl/accessControl.service.ts @@ -1,15 +1,15 @@ -import { InjectModel } from '@nestjs/sequelize' import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/sequelize' import { Op } from 'sequelize' import { RecyclingPartnerModel } from '../recyclingPartner' -import { AccessControlModel, AccessControlRole } from './accessControl.model' import { CreateAccessControlInput, DeleteAccessControlInput, UpdateAccessControlInput, } from './accessControl.input' +import { AccessControlModel, AccessControlRole } from './accessControl.model' @Injectable() export class AccessControlService { @@ -34,13 +34,26 @@ export class AccessControlService { async findByRecyclingPartner( partnerId: string, ): Promise { + let partnerIds = [] + + // Get all sub recycling partners of the municipality + // else get all + if (partnerId) { + const subRecyclingPartners = await RecyclingPartnerModel.findAll({ + where: { municipalityId: partnerId }, + }) + + partnerIds = [ + partnerId, + ...subRecyclingPartners.map((partner) => partner.companyId), + ] + } else { + partnerIds = null + } + return this.accessControlModel.findAll({ - where: { partnerId, role: ['recyclingCompany', 'recyclingCompanyAdmin'] }, - include: [ - { - model: RecyclingPartnerModel, - }, - ], + where: { partnerId: partnerIds }, + include: [RecyclingPartnerModel], }) } diff --git a/apps/skilavottord/ws/src/app/modules/auth/auth.guard.ts b/apps/skilavottord/ws/src/app/modules/auth/auth.guard.ts index 1c667edf14e4..2ab026916e5b 100644 --- a/apps/skilavottord/ws/src/app/modules/auth/auth.guard.ts +++ b/apps/skilavottord/ws/src/app/modules/auth/auth.guard.ts @@ -75,27 +75,8 @@ export class AuthGuard implements CanActivate { export const Authorize = ( { roles = [] }: AuthorizeOptions = { roles: [] }, ): MethodDecorator & ClassDecorator => { - logger.info(`car-recycling: AuthGuard environment #1`, { - environment: process.env.NODE_ENV, - isProduction: isRunningOnEnvironment('production'), - }) - - // IdsUserGuard is causing constant reload on local and DEV in the skilavottord-web - // To 'fix' it for now we just skip using it for non production - if ( - process.env.NODE_ENV !== 'production' || - !isRunningOnEnvironment('production') - ) { - logger.info('`car-recycling: AuthGuard - skipping IdsUserGuard') - return applyDecorators( - SetMetadata('roles', roles), - UseGuards(AuthGuard, RolesGuard), - ) - } - - // We are getting Invalid User error on PROD if we skip IdsUserGuard for some reason return applyDecorators( SetMetadata('roles', roles), - UseGuards(IdsUserGuard, AuthGuard, RolesGuard), + UseGuards(AuthGuard, RolesGuard), ) } diff --git a/apps/skilavottord/ws/src/app/modules/auth/user.model.ts b/apps/skilavottord/ws/src/app/modules/auth/user.model.ts index 3a650dad9418..24ee03634b72 100644 --- a/apps/skilavottord/ws/src/app/modules/auth/user.model.ts +++ b/apps/skilavottord/ws/src/app/modules/auth/user.model.ts @@ -6,6 +6,7 @@ export enum Role { recyclingCompanyAdmin = 'recyclingCompanyAdmin', recyclingFund = 'recyclingFund', citizen = 'citizen', + municipality = 'municipality', } registerEnumType(Role, { name: 'Role' }) diff --git a/apps/skilavottord/ws/src/app/modules/index.ts b/apps/skilavottord/ws/src/app/modules/index.ts index e4b1468e21b0..8becbef0fe93 100644 --- a/apps/skilavottord/ws/src/app/modules/index.ts +++ b/apps/skilavottord/ws/src/app/modules/index.ts @@ -7,3 +7,4 @@ export { RecyclingRequestModule } from './recyclingRequest/recyclingRequest.modu export { SamgongustofaModule } from './samgongustofa/samgongustofa.module' export { VehicleModule } from './vehicle/vehicle.module' export { VehicleOwnerModule } from './vehicleOwner/vehicleOwner.module' +export { MunicipalityModule } from './municipality/municipality.module' diff --git a/apps/skilavottord/ws/src/app/modules/municipality/index.ts b/apps/skilavottord/ws/src/app/modules/municipality/index.ts new file mode 100644 index 000000000000..fe55d7539d2c --- /dev/null +++ b/apps/skilavottord/ws/src/app/modules/municipality/index.ts @@ -0,0 +1,2 @@ +export { MunicipalityModel } from './municipality.model' +export { MunicipalityService } from './municipality.service' diff --git a/apps/skilavottord/ws/src/app/modules/municipality/municipality.model.ts b/apps/skilavottord/ws/src/app/modules/municipality/municipality.model.ts new file mode 100644 index 000000000000..1f969762d852 --- /dev/null +++ b/apps/skilavottord/ws/src/app/modules/municipality/municipality.model.ts @@ -0,0 +1,20 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql' +import { Column, DataType, Model, Table } from 'sequelize-typescript' + +@ObjectType('Municipality') +@Table({ tableName: 'municipality_view', timestamps: false }) +export class MunicipalityModel extends Model { + @Field(() => ID) + @Column({ + type: DataType.STRING, + field: 'id', + }) + companyId!: string + + @Field() + @Column({ + type: DataType.STRING, + field: 'company_name', + }) + companyName!: string +} diff --git a/apps/skilavottord/ws/src/app/modules/municipality/municipality.module.ts b/apps/skilavottord/ws/src/app/modules/municipality/municipality.module.ts new file mode 100644 index 000000000000..bf7f93719f39 --- /dev/null +++ b/apps/skilavottord/ws/src/app/modules/municipality/municipality.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { SequelizeModule } from '@nestjs/sequelize' + +import { MunicipalityModel } from './municipality.model' +import { MunicipalityResolver } from './municipality.resolver' +import { MunicipalityService } from './municipality.service' + +@Module({ + imports: [SequelizeModule.forFeature([MunicipalityModel])], + providers: [MunicipalityResolver, MunicipalityService], + exports: [MunicipalityService], +}) +export class MunicipalityModule {} diff --git a/apps/skilavottord/ws/src/app/modules/municipality/municipality.resolver.ts b/apps/skilavottord/ws/src/app/modules/municipality/municipality.resolver.ts new file mode 100644 index 000000000000..e33be348b5cc --- /dev/null +++ b/apps/skilavottord/ws/src/app/modules/municipality/municipality.resolver.ts @@ -0,0 +1,22 @@ +import { Query, Resolver } from '@nestjs/graphql' + +import { Authorize, Role } from '../auth' + +import { MunicipalityModel } from './municipality.model' +import { MunicipalityService } from './municipality.service' + +@Authorize() +@Resolver(() => MunicipalityModel) +export class MunicipalityResolver { + constructor(private municipalityService: MunicipalityService) {} + + @Authorize({ + roles: [Role.developer, Role.recyclingFund, Role.municipality], + }) + @Query(() => [MunicipalityModel], { + name: 'skilavottordAllMunicipalities', + }) + async skilavottordAllMunicipalities(): Promise { + return this.municipalityService.findAll() + } +} diff --git a/apps/skilavottord/ws/src/app/modules/municipality/municipality.service.ts b/apps/skilavottord/ws/src/app/modules/municipality/municipality.service.ts new file mode 100644 index 000000000000..35e3231a635d --- /dev/null +++ b/apps/skilavottord/ws/src/app/modules/municipality/municipality.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/sequelize' + +import { MunicipalityModel } from './municipality.model' + +@Injectable() +export class MunicipalityService { + constructor( + @InjectModel(MunicipalityModel) + private municipalityModel: typeof MunicipalityModel, + ) {} + + async findAll(): Promise { + return await this.municipalityModel.findAll() + } +} diff --git a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.input.ts b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.input.ts index 5aeee322ba5d..645cbbfe50a4 100644 --- a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.input.ts +++ b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.input.ts @@ -37,6 +37,12 @@ export class CreateRecyclingPartnerInput { @Field() email?: string + + @Field() + isMunicipality?: boolean + + @Field({ nullable: true }) + municipalityId?: string } @InputType() @@ -70,4 +76,10 @@ export class UpdateRecyclingPartnerInput { @Field() email?: string + + @Field({ nullable: true }) + isMunicipality?: boolean + + @Field({ nullable: true }) + municipalityId?: string } diff --git a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.model.ts b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.model.ts index 40133e5c22dd..c36c7e644ded 100644 --- a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.model.ts +++ b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.model.ts @@ -1,16 +1,17 @@ import { Field, ID, ObjectType } from '@nestjs/graphql' import { + BelongsTo, Column, + CreatedAt, DataType, Model, + PrimaryKey, Table, - CreatedAt, UpdatedAt, - HasMany, - PrimaryKey, } from 'sequelize-typescript' import { RecyclingRequestModel } from '../recyclingRequest' +import { MunicipalityModel } from '../municipality/municipality.model' @ObjectType('RecyclingPartner') @Table({ tableName: 'recycling_partner' }) @@ -98,7 +99,27 @@ export class RecyclingPartnerModel extends Model { }) updatedAt: Date + @Field() + @Column({ + type: DataType.BOOLEAN, + field: 'is_municipality', + }) + isMunicipality!: boolean + + @Field({ nullable: true }) // Ensure this field is nullable in GraphQL + @Column({ + type: DataType.STRING, + field: 'municipality_id', + allowNull: true, // Ensure this field allows null in Sequelize + }) + municipalityId?: string + @Field(() => [RecyclingRequestModel]) - @HasMany(() => RecyclingRequestModel) recyclingRequests?: typeof RecyclingRequestModel[] + @Field(() => MunicipalityModel, { nullable: true }) + @BelongsTo(() => MunicipalityModel, { + foreignKey: 'municipality_id', + as: 'municipality', + }) + municipality?: MunicipalityModel } diff --git a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.module.ts b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.module.ts index 64ee9ee75ae0..05e7a167af7e 100644 --- a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.module.ts +++ b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.module.ts @@ -4,9 +4,13 @@ import { SequelizeModule } from '@nestjs/sequelize' import { RecyclingPartnerModel } from './recyclingPartner.model' import { RecyclingPartnerResolver } from './recyclingPartner.resolver' import { RecyclingPartnerService } from './recyclingPartner.service' +import { MunicipalityModel } from '../municipality/municipality.model' @Module({ - imports: [SequelizeModule.forFeature([RecyclingPartnerModel])], + imports: [ + SequelizeModule.forFeature([RecyclingPartnerModel]), + SequelizeModule.forFeature([MunicipalityModel]), + ], providers: [RecyclingPartnerResolver, RecyclingPartnerService], exports: [RecyclingPartnerService], }) diff --git a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.resolver.ts b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.resolver.ts index 66fb91a354a4..e78186fc61df 100644 --- a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.resolver.ts +++ b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.resolver.ts @@ -1,15 +1,20 @@ +import { + BadRequestException, + ConflictException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common' import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' -import { ConflictException, NotFoundException } from '@nestjs/common' import { Authorize, Role } from '../auth' -import { RecyclingPartnerModel } from './recyclingPartner.model' -import { RecyclingPartnerService } from './recyclingPartner.service' import { CreateRecyclingPartnerInput, RecyclingPartnerInput, UpdateRecyclingPartnerInput, } from './recyclingPartner.input' +import { RecyclingPartnerModel } from './recyclingPartner.model' +import { RecyclingPartnerService } from './recyclingPartner.service' @Authorize() @Resolver(() => RecyclingPartnerModel) @@ -17,13 +22,39 @@ export class RecyclingPartnerResolver { constructor(private recyclingPartnerService: RecyclingPartnerService) {} @Authorize({ - roles: [Role.developer, Role.recyclingFund], + roles: [Role.developer, Role.recyclingFund, Role.municipality], }) - @Query(() => [RecyclingPartnerModel]) - async skilavottordAllRecyclingPartners(): Promise { + @Query(() => [RecyclingPartnerModel], { + name: 'skilavottordAllRecyclingPartners', + }) + async getAllRecyclingPartners(): Promise { return this.recyclingPartnerService.findAll() } + @Authorize({ + roles: [Role.developer, Role.recyclingFund, Role.municipality], + }) + @Query(() => [RecyclingPartnerModel], { + name: 'skilavottordRecyclingPartners', + }) + async skilavottordRecyclingPartners( + @Args('isMunicipalityPage', { type: () => Boolean, nullable: true }) + isMunicipalityPage: boolean, + @Args('municipalityId', { type: () => String, nullable: true }) + municipalityId: string | null, + ): Promise { + try { + return this.recyclingPartnerService.findRecyclingPartners( + isMunicipalityPage, + municipalityId, + ) + } catch (error) { + throw new InternalServerErrorException( + `Failed to fetch recycling partners: ${error.message}`, + ) + } + } + @Query(() => [RecyclingPartnerModel]) async skilavottordAllActiveRecyclingPartners(): Promise< RecyclingPartnerModel[] @@ -32,7 +63,7 @@ export class RecyclingPartnerResolver { } @Authorize({ - roles: [Role.developer, Role.recyclingFund], + roles: [Role.developer, Role.recyclingFund, Role.municipality], }) @Query(() => RecyclingPartnerModel) async skilavottordRecyclingPartner( @@ -43,7 +74,7 @@ export class RecyclingPartnerResolver { } @Authorize({ - roles: [Role.developer, Role.recyclingFund], + roles: [Role.developer, Role.recyclingFund, Role.municipality], }) @Mutation(() => RecyclingPartnerModel) async createSkilavottordRecyclingPartner( @@ -64,7 +95,7 @@ export class RecyclingPartnerResolver { } @Authorize({ - roles: [Role.developer, Role.recyclingFund], + roles: [Role.developer, Role.recyclingFund, Role.municipality], }) @Mutation(() => RecyclingPartnerModel) async updateSkilavottordRecyclingPartner( diff --git a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.service.ts b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.service.ts index d3fa410e95b3..1cf954bf3ec6 100644 --- a/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.service.ts +++ b/apps/skilavottord/ws/src/app/modules/recyclingPartner/recyclingPartner.service.ts @@ -6,6 +6,7 @@ import { CreateRecyclingPartnerInput, UpdateRecyclingPartnerInput, } from './recyclingPartner.input' +import { Op } from 'sequelize' @Injectable() export class RecyclingPartnerService { @@ -24,6 +25,36 @@ export class RecyclingPartnerService { }) } + async findRecyclingPartners( + isMunicipalityPage: boolean, + municipalityId: string | null | undefined, + ): Promise { + try { + // If Role.municipality, return all recycling partners that are not municipalities + if (municipalityId) { + return await this.recyclingPartnerModel.findAll({ + where: { + isMunicipality: false, + municipalityId: municipalityId, + }, + }) + } + + if (isMunicipalityPage) { + return await this.recyclingPartnerModel.findAll({ + where: { isMunicipality: true }, + }) + } + return await this.recyclingPartnerModel.findAll({ + where: { + [Op.or]: [{ isMunicipality: false }, { isMunicipality: null }], + }, + }) + } catch (error) { + throw new Error(`Failed to fetch recycling partners: ${error.message}`) + } + } + async findOne(companyId: string): Promise { return this.recyclingPartnerModel.findOne({ where: { companyId }, @@ -50,4 +81,21 @@ export class RecyclingPartnerService { ) return accessControl } + + /** + * Get the id of the municipality or recyclingpartner that is paying for the recycling request + * @param companyId + * @returns + */ + async getPayingPartnerId(companyId: string): Promise { + const partner = await this.recyclingPartnerModel.findOne({ + where: { companyId }, + }) + + if (!partner) { + throw new Error(`Partner not found for company ID: ${companyId}`) + } + + return partner.municipalityId ?? partner.companyId + } } diff --git a/apps/skilavottord/ws/src/app/modules/recyclingRequest/recyclingRequest.service.ts b/apps/skilavottord/ws/src/app/modules/recyclingRequest/recyclingRequest.service.ts index afb5ea568a87..630c8b496f40 100644 --- a/apps/skilavottord/ws/src/app/modules/recyclingRequest/recyclingRequest.service.ts +++ b/apps/skilavottord/ws/src/app/modules/recyclingRequest/recyclingRequest.service.ts @@ -38,7 +38,7 @@ export class RecyclingRequestService { private httpService: HttpService, private fjarsyslaService: FjarsyslaService, @Inject(forwardRef(() => RecyclingPartnerService)) - private recycllingPartnerService: RecyclingPartnerService, + private recyclingPartnerService: RecyclingPartnerService, private vehicleService: VehicleService, private icelandicTransportAuthorityServices: IcelandicTransportAuthorityServices, ) {} @@ -48,6 +48,9 @@ export class RecyclingRequestService { disposalStation: string, vehicle: VehicleModel, ) { + const disposalStationId = + await this.recyclingPartnerService.getPayingPartnerId(disposalStation) + try { const { restAuthUrl, restDeRegUrl, restUsername, restPassword } = environment.samgongustofa @@ -78,7 +81,7 @@ export class RecyclingRequestService { const jsonDeRegBody = JSON.stringify({ permno: vehiclePermno, deRegisterDate: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss"), - disposalStation: disposalStation, + disposalStation: disposalStationId, explanation: 'Rafrænt afskráning', mileage: vehicle.mileage ?? 0, plateCount: vehicle.plateCount, @@ -264,7 +267,7 @@ export class RecyclingRequestService { // is partnerId null? if (partnerId) { newRecyclingRequest.recyclingPartnerId = partnerId - const partner = await this.recycllingPartnerService.findOne(partnerId) + const partner = await this.recyclingPartnerService.findOne(partnerId) if (!partner) { this.logger.error( `car-recycling: The recycling station is not in the recycling station list`, diff --git a/apps/skilavottord/ws/src/app/modules/vehicle/vehicle.resolver.ts b/apps/skilavottord/ws/src/app/modules/vehicle/vehicle.resolver.ts index d8a1516ffda1..ef376ec07be1 100644 --- a/apps/skilavottord/ws/src/app/modules/vehicle/vehicle.resolver.ts +++ b/apps/skilavottord/ws/src/app/modules/vehicle/vehicle.resolver.ts @@ -18,15 +18,17 @@ export class VehicleResolver { private samgongustofaService: SamgongustofaService, ) {} - @Authorize({ roles: [Role.developer, Role.recyclingFund] }) + @Authorize({ roles: [Role.developer, Role.recyclingFund, Role.municipality] }) @Query(() => VehicleConnection) async skilavottordAllDeregisteredVehicles( + @CurrentUser() user: User, @Args('first', { type: () => Int }) first: number, @Args('after') after: string, ): Promise { const { pageInfo, totalCount, data } = await this.vehicleService.findAllByFilter(first, after, { requestType: RecyclingRequestTypes.deregistered, + partnerId: user.role === Role.municipality ? user.partnerId : null, }) return { pageInfo, diff --git a/apps/skilavottord/ws/src/app/modules/vehicle/vehicle.service.ts b/apps/skilavottord/ws/src/app/modules/vehicle/vehicle.service.ts index 67729e02c3a9..b43765aee663 100644 --- a/apps/skilavottord/ws/src/app/modules/vehicle/vehicle.service.ts +++ b/apps/skilavottord/ws/src/app/modules/vehicle/vehicle.service.ts @@ -1,14 +1,15 @@ +import type { Logger } from '@island.is/logging' +import { LOGGER_PROVIDER } from '@island.is/logging' +import { paginate } from '@island.is/nest/pagination' import { Inject, Injectable } from '@nestjs/common' import { InjectModel } from '@nestjs/sequelize' -import { paginate } from '@island.is/nest/pagination' +import { RecyclingPartnerModel } from '../recyclingPartner' +import { MunicipalityModel } from '../municipality/municipality.model' import { RecyclingRequestModel, RecyclingRequestTypes, } from '../recyclingRequest' -import { RecyclingPartnerModel } from '../recyclingPartner' import { VehicleModel } from './vehicle.model' -import type { Logger } from '@island.is/logging' -import { LOGGER_PROVIDER } from '@island.is/logging' @Injectable() export class VehicleService { @@ -23,6 +24,31 @@ export class VehicleService { after: string, filter?: { requestType?: RecyclingRequestTypes; partnerId?: string }, ) { + let partnerIds = [] + + // Get all sub recycling partners of the municipality + // else get all + if (filter.partnerId) { + try { + const recyclingPartners = await RecyclingPartnerModel.findAll({ + where: { + municipalityId: filter.partnerId, + }, + attributes: ['companyId', 'companyName', 'municipalityId'], + }) + + partnerIds = [ + filter.partnerId, + ...recyclingPartners.map((partner) => partner.companyId), + ] + } catch (error) { + this.logger.error('Failed to fetch sub-partners:', error) + throw new Error('Failed to process municipality partners') + } + } else { + partnerIds = null + } + return paginate({ Model: this.vehicleModel, limit: first, @@ -34,15 +60,25 @@ export class VehicleService { model: RecyclingRequestModel, where: { ...(filter.requestType ? { requestType: filter.requestType } : {}), - ...(filter.partnerId + ...(partnerIds ? { - recyclingPartnerId: filter.partnerId, + recyclingPartnerId: partnerIds, } : {}), }, include: [ { model: RecyclingPartnerModel, + attributes: ['companyId', 'companyName', 'municipalityId'], + required: false, // Allows for left join + include: [ + { + model: MunicipalityModel, + attributes: ['companyName'], + as: 'municipality', + required: false, + }, + ], }, ], },