import { BadRequestException , Controller , Get , Header , InternalServerErrorException , Logger , NotFoundException , Param , Post , ParseIntPipe , Query , Body , Request , Res , UseGuards , UseInterceptors } from "@nestjs/common" ; import { Crud , CrudAuth , CrudController , CrudRequest , CrudRequestInterceptor , Override , ParsedBody , ParsedRequest } from "@nestjsx/crud" ; import stringify from "csv-stringify/lib/sync" ; import { Response } from "express" ; import { convert } from "geojson2shp" ; import * as _ from "lodash" ; import isUUID from "validator/lib/isUUID" ; import { Pagination } from "nestjs-typeorm-paginate" ; import { MakeDistrictsErrors , ConvertProjectErrors , CORE_METRIC_FIELDS } from "../../../../shared/constants" ; import { DistrictsDefinition , ProjectId , PublicUserProperties , GeoUnitHierarchy , UserId , ProjectTemplateId , IUser , IChamber , IRegionConfig } from "../../../../shared/entities" ; import { ProjectVisibility } from "../../../../shared/constants" ; import { GeoUnitTopology } from "../../districts/entities/geo-unit-topology.entity" ; import { TopologyService } from "../../districts/services/topology.service" ; import { JwtAuthGuard , OptionalJwtAuthGuard } from "../../auth/guards/jwt-auth.guard" ; import { RegionConfig } from "../../region-configs/entities/region-config.entity" ; import { User } from "../../users/entities/user.entity" ; import { CreateProjectDto } from "../entities/create-project.dto" ; import { DistrictsGeoJSON , Project } from "../entities/project.entity" ; import { ProjectsService } from "../services/projects.service" ; import { OrganizationsService } from "../../organizations/services/organizations.service" ; import { RegionConfigsService } from "../../region-configs/services/region-configs.service" ; import { UsersService } from "../../users/services/users.service" ; import { UpdateProjectDto } from "../entities/update-project.dto" ; import { Errors } from "../../../../shared/types" ; import axios from "axios" ; import { CrosswalkService } from "../services/crosswalk.service" ; import { GeoUnitProperties } from "../../districts/entities/geo-unit-properties.entity" ; import { Brackets } from "typeorm" ; import { getDemographicsMetricFields , getVotingMetricFields } from "../../../../shared/functions" ; import { ProjectTemplatesService } from "../../project-templates/services/project-templates.service" ; import { ProjectTemplate } from "../../project-templates/entities/project-template.entity" ; import { ReferenceLayersService } from "../../reference-layers/services/reference-layers.service" ; import { ChambersService } from "../../chambers/services/chambers" ; function validateNumberOfMembers ( dto , numberOfDistricts ) { if ( dto . numberOfMembers && numberOfDistricts !== dto . numberOfMembers . length ) { throw new BadRequestException ( { error : "Bad Request" , message : { numberOfMembers : [ `Length of array does not match "numberOfDistricts"` ] } } as Errors < UpdateProjectDto > ) ; } if ( dto . numberOfMembers && dto . numberOfMembers . some ( num => num === 0 ) ) { throw new BadRequestException ( { error : "Bad Request" , message : { numberOfMembers : [ `Districts cannot have 0 representatives` ] } } as Errors < UpdateProjectDto > ) ; } if ( dto . numberOfMembers && dto . numberOfMembers . some ( num => num === null || Number . isNaN ( num ) ) ) { throw new BadRequestException ( { error : "Bad Request" , message : { numberOfMembers : [ `Number of representatives is required for every district` ] } } as Errors < UpdateProjectDto > ) ; } } @ Crud ( { model : { type : Project } , params : { id : { type : "string" , primary : true , field : "id" } } , query : { exclude : [ "districts" ] , join : { chamber : { eager : true } , projectTemplate : { exclude : [ "districtsDefinition" ] , eager : true } , "projectTemplate.organization" : { eager : true } , "projectTemplate.organization.admin" : { alias : "org_admin" , eager : false } , "projectTemplate.regionConfig" : { alias : "template_region_config" , eager : true } , regionConfig : { eager : true } , user : { allow : [ "id" , "name" ] as PublicUserProperties [ ] , alias : "project_user" , required : true , eager : true } } } , routes : { only : [ "createOneBase" , "getManyBase" , "getOneBase" , "updateOneBase" ] } , dto : { update : UpdateProjectDto } } ) @ CrudAuth ( { filter : ( req ) => { const user = req . user as User ; const endpoint = req . route . path . split ( "/" ) . reverse ( ) [ 0 ] ; if ( endpoint === "toggleFeatured" ) { return { "projectTemplate.organization.admin" : user . id } ; } else if ( req . method !== "GET" && endpoint !== "duplicate" ) { return { "project_user.id" : user ? user . id : undefined } ; } else { const publicallyVisible = [ { visibility : ProjectVisibility . Published } , { visibility : ProjectVisibility . Visible } ] ; const visibleFilter = user ? [ { "project_user.id" : user . id } , ... publicallyVisible ] : publicallyVisible ; return { $and : [ { $or : visibleFilter } , { archived : false } ] } ; } } , persist : ( req ) => { const user = req . user as User ; return { userId : user ? user . id : undefined } ; } } ) @ Controller ( "api/projects" ) export class ProjectsController implements CrudController < Project > { get base ( ) : CrudController < Project > { return this ; } private readonly logger = new Logger ( ProjectsController . name ) ; constructor ( public service , public templateService , public topologyService , private readonly usersService , private readonly organizationService , private readonly regionConfigService , private readonly referenceLayerService , private readonly crosswalkService , private readonly chambersService ) { } private formatCreateProjectDto ( dto , geoCollection , regionConfig , req ) { const districtsDefinition = dto . districtsDefinition || new Array ( geoCollection . hierarchy . length ) . fill ( 0 ) ; const lockedDistricts = new Array ( dto . numberOfDistricts ) . fill ( false ) ; const numberOfMembers = dto . numberOfMembers || new Array ( dto . numberOfDistricts ) . fill ( 1 ) ; return { ... dto , districtsDefinition , lockedDistricts , numberOfMembers , user : req . parsed . authPersist . userId , regionConfigVersion : regionConfig . version } ; } @ UseGuards ( JwtAuthGuard ) @ UseInterceptors ( CrudRequestInterceptor ) @ Post ( ":id/duplicate" ) async duplicate ( @ ParsedRequest ( ) req , @ Param ( "id" ) id ) { const userId = typeof req . parsed . authPersist . userId === "string" ? req . parsed . authPersist . userId : undefined ; const project = await this . getProjectWithDistricts ( id , userId ) ; const geoCollection = await this . topologyService . get ( project . regionConfig . s3URI ) ; if ( ! geoCollection ) { throw new NotFoundException ( `Topology ${ project . regionConfig . s3URI } not found` , MakeDistrictsErrors . TOPOLOGY_NOT_FOUND ) ; } const user = await this . usersService . findOne ( userId ) ; if ( ! user ) { throw new InternalServerErrorException ( `User not found for authenticated user id ${ userId }` ) ; } const dto = { ... project , name : `Copy of ${ project . name }` , id : undefined , user : undefined , createdDt : undefined , updatedDt : undefined , isFeatured : undefined , districts : project . districts ?. metadata ?. creator ? { ... project . districts , metadata : { ... project . districts . metadata , creator : { id : user . id , name : user . name } } } : project . districts } ; try { return await this . service . save ( this . formatCreateProjectDto ( dto , geoCollection , project . regionConfig , req ) ) ; } catch ( error ) { this . logger . error ( `Error creating project: ${ error }` ) ; throw new InternalServerErrorException ( ) ; } } async getProject ( req , projectId ) { if ( ! this . base . getOneBase ) { this . logger . error ( "Routes misconfigured. Missing `getOneBase` route" ) ; throw new InternalServerErrorException ( ) ; } if ( ! isUUID ( projectId ) ) { throw new NotFoundException ( `Project ${ projectId } is not a valid UUID` ) ; } const project = await this . base . getOneBase ( req ) . then ( project => { return project . user . id === req . parsed . authPersist . userId ? project : project . getReadOnlyView ( ) ; } ) ; if ( ! project ) { throw new NotFoundException ( `Project ${ projectId } not found` ) ; } return project ; } async getProjectWithDistricts ( id , userId ? ) { if ( ! isUUID ( id ) ) { throw new NotFoundException ( `Project ${ id } is not a valid UUID` ) ; } const project = await this . service . findOne ( { where : new Brackets ( qb => qb . where ( { id , archived : false } ) . andWhere ( new Brackets ( qb => { const isVisibleFilter = qb . where ( "visibility = :published" , { published : ProjectVisibility . Published } ) . orWhere ( "visibility = :visible" , { visible : ProjectVisibility . Visible } ) ; return userId ? isVisibleFilter . orWhere ( "user_id = :userId" , { userId } ) : isVisibleFilter ; } ) ) ) , loadEagerRelations : false , relations : [ "regionConfig" , "projectTemplate" , "user" , "chamber" ] } ) ; if ( ! project ) { throw new NotFoundException ( `Project ${ id } not found` ) ; } return project ; } async getGeoUnitTopology ( s3URI ) { const geoCollection = await this . getGeoUnitProperties ( s3URI ) ; if ( ! ( "topology" in geoCollection ) ) { throw new NotFoundException ( `Topology ${ s3URI } is archived` , MakeDistrictsErrors . TOPOLOGY_NOT_FOUND ) ; } return geoCollection ; } async getGeoUnitProperties ( s3URI ) { const geoCollection = await this . topologyService . get ( s3URI ) ; if ( ! geoCollection ) { throw new NotFoundException ( `Topology ${ s3URI } not found` , MakeDistrictsErrors . TOPOLOGY_NOT_FOUND ) ; } return geoCollection ; } async getGeojson ( { districtsDefinition , numberOfDistricts , user , chamber , regionConfig } ) { const geoCollection = await this . getGeoUnitTopology ( regionConfig . s3URI ) ; const geojson = geoCollection . merge ( { districtsDefinition , numberOfDistricts , user , chamber , regionConfig } ) ; if ( geojson === null ) { this . logger . error ( `Invalid districts definition for project` ) ; throw new BadRequestException ( "District definition is invalid" , MakeDistrictsErrors . INVALID_DEFINITION ) ; } return geojson ; } @ UseInterceptors ( CrudRequestInterceptor ) @ UseGuards ( OptionalJwtAuthGuard ) @ Get ( ":id/export/geojson" ) async exportGeoJSON ( @ Request ( ) req , @ Param ( "id" ) id ) { const user = req . user as User ; const project = await this . getProjectWithDistricts ( id , user ?. id ) ; if ( project . regionConfig . archived && ! project . districts ) { throw new BadRequestException ( "Saved district is not available and cannot be calculated" , MakeDistrictsErrors . INVALID_DEFINITION ) ; } if ( ! project . districts || project . regionConfigVersion . getTime ( ) !== project . regionConfig . version . getTime ( ) ) { const districts = await this . getGeojson ( project ) ; void this . service . save ( { ... project , districts , regionConfigVersion : project . regionConfig . version } ) ; return districts ; } return project . districts ; } @ UseInterceptors ( CrudRequestInterceptor ) @ UseGuards ( OptionalJwtAuthGuard ) @ Get ( ":id/export/shp" ) async exportShapefile ( @ Request ( ) req , @ Param ( "id" ) projectId , @ Res ( ) response ) { const geojson = await this . exportGeoJSON ( req , projectId ) ; const formattedGeojson = { ... geojson , features : geojson . features . map ( feature => ( { ... feature , properties : { ... feature . properties , demographics : undefined , voting : undefined , ... feature . properties . demographics , ... feature . properties . voting , id : feature . id } } ) ) } ; await convert ( formattedGeojson , response , { layer : "districts" } ) ; } exportToCsv ( geoCollection , districtsDefinition ) { const baseGeoLevel = geoCollection . definition . groups . slice ( ) . reverse ( ) [ 0 ] ; const baseGeoUnitProperties = geoCollection . topologyProperties [ baseGeoLevel ] ; const mutableCsvRows = [ ] ; const accumulateCsvRows = ( defnSubset , hierarchySubset ) => { hierarchySubset . forEach ( ( hierarchyNumOrArray , idx ) => { const districtOrArray = typeof defnSubset === "number" ? defnSubset : defnSubset [ idx ] ; if ( typeof districtOrArray === "number" && typeof hierarchyNumOrArray === "number" ) { const props = baseGeoUnitProperties [ hierarchyNumOrArray ] ; const baseId = props [ baseGeoLevel ] as string ; mutableCsvRows . push ( [ baseId , districtOrArray ] ) ; } else if ( typeof hierarchyNumOrArray !== "number" ) { accumulateCsvRows ( districtOrArray , hierarchyNumOrArray ) ; } else { this . logger . error ( "Hierarchy and districts definition mismatch" , districtOrArray . toString ( ) , hierarchyNumOrArray . toString ( ) ) ; throw new InternalServerErrorException ( ) ; } } ) ; } ; accumulateCsvRows ( districtsDefinition , geoCollection . hierarchyDefinition ) ; return mutableCsvRows ; } @ UseInterceptors ( CrudRequestInterceptor ) @ UseGuards ( OptionalJwtAuthGuard ) @ Get ( ":id/export/csv" ) @ Header ( "Content-Type" , "text/csv" ) async exportCsv ( @ ParsedRequest ( ) req , @ Param ( "id" ) projectId ) { const project = await this . getProject ( req , projectId ) ; const geoCollection = await this . getGeoUnitProperties ( project . regionConfig . s3URI ) ; const baseGeoLevel = geoCollection . definition . groups . slice ( ) . reverse ( ) [ 0 ] ; const csvRows = this . exportToCsv ( geoCollection , project . districtsDefinition ) ; return stringify ( csvRows , { header : true , columns : [ `${ baseGeoLevel . toUpperCase ( ) }ID` , "DISTRICT" ] } ) ; } @ UseInterceptors ( CrudRequestInterceptor ) @ UseGuards ( JwtAuthGuard ) @ Post ( ":id/convert-and-copy" ) async convertAndCopy ( @ ParsedRequest ( ) req , @ Param ( "id" ) projectId ) { const project = await this . getProject ( req , projectId ) ; const regionCode = project . regionConfig . regionCode ; if ( ! project . regionConfig . archived ) { throw new BadRequestException ( "The project region must be archived to convert it" , ConvertProjectErrors . REGION_NOT_ARCHIVED ) ; } const newRegion = await this . regionConfigService . findOne ( { where : { archived : false , regionCode } } ) ; if ( newRegion === undefined || newRegion . archived ) { throw new BadRequestException ( `There is not an unarchived region for ${ regionCode }` , ConvertProjectErrors . NO_ACTIVE_REGION ) ; } const crosswalk = await this . crosswalkService . getCrosswalk ( regionCode ) ; if ( crosswalk === undefined ) { throw new BadRequestException ( `There is not an unarchived region for ${ regionCode }` , ConvertProjectErrors . NO_ACTIVE_REGION ) ; } const archivedTopoProperties = await this . getGeoUnitProperties ( project . regionConfig . s3URI ) ; const newGeoUnitTopology = await this . getGeoUnitTopology ( newRegion . s3URI ) ; const oldBlockToDistricts = Object . fromEntries ( this . exportToCsv ( archivedTopoProperties , project . districtsDefinition ) ) ; const blockSums = { } ; for ( const [ newFips , oldBlocks ] of Object . entries ( crosswalk ) ) { for ( const { fips , amount } of oldBlocks ) { const districtId = oldBlockToDistricts [ fips ] ; const districtSums = blockSums [ newFips ] || { } ; const sum = ( districtSums [ districtId ] || 0 ) + amount ; blockSums [ newFips ] = { ... districtSums , [ districtId ] : sum } ; } } const blockToDistricts = _ . mapValues ( blockSums , districtSums => { const [ largestDistrict ] = _ . maxBy ( Object . entries ( districtSums ) , ( [ , sum ] ) => sum ) || [ 0 , 0 ] ; return Number ( largestDistrict ) ; } ) ; const districtsDefinition = newGeoUnitTopology . importFromCSV ( blockToDistricts ) ; const districts = await this . getGeojson ( { numberOfDistricts : project . numberOfDistricts , user : project . user , regionConfig : newRegion , chamber : project . chamber , districtsDefinition } ) ; try { return this . service . save ( { name : `${ project . name } (2020)` , regionConfig : newRegion , regionConfigVersion : newRegion . version , chamber : project . chamber , projectTemplate : project . projectTemplate , numberOfDistricts : project . numberOfDistricts , user : req . parsed . authPersist . userId , visibility : project . visibility , lockedDistricts : project . lockedDistricts , populationDeviation : project . populationDeviation , pinnedMetricFields : project . pinnedMetricFields , districtsDefinition , districts } ) ; } catch ( error ) { this . logger . error ( `Error creating converted project: ${ error }` ) ; throw new InternalServerErrorException ( ) ; } } @ UseInterceptors ( CrudRequestInterceptor ) @ UseGuards ( JwtAuthGuard ) @ Post ( ":id/toggleFeatured" ) async setProjectAsFeatured ( @ ParsedRequest ( ) req , @ Param ( "id" ) projectId , @ Body ( ) projectFeatured ) { const project = await this . getProject ( req , projectId ) ; if ( ! project . projectTemplate ) { throw new NotFoundException ( `Project is not connected to an organization's template` ) ; } const orgId = project . projectTemplate . organization . id ; if ( ! orgId ) { throw new NotFoundException ( `Project is not connected to an organization` ) ; } const userId = req . parsed . authPersist . userId || null ; const org = await this . organizationService . findOne ( { id : orgId } , { relations : [ "admin" ] } ) ; const user = await this . usersService . findOne ( { id : userId } ) ; if ( ! user || ! org ) { throw new NotFoundException ( `Unable to find user: ${ userId }` ) ; } if ( ! org . admin ) { throw new NotFoundException ( `Organization ${ orgId } does not have an admin` ) ; } if ( org . admin . id !== userId ) { throw new NotFoundException ( `User does not have admin privileges for organization: ${ orgId }` ) ; } project . isFeatured = projectFeatured . isFeatured ; await this . service . save ( project ) ; return project ; } @ UseInterceptors ( CrudRequestInterceptor ) @ UseGuards ( OptionalJwtAuthGuard ) @ Post ( ":id/planScore" ) async sendToPlanScoreAPI ( @ Request ( ) req , @ Param ( "id" ) projectId ) { const planScoreToken = process . env . PLAN_SCORE_API_TOKEN || "" ; const districts = await this . exportGeoJSON ( req , projectId ) ; const geojson = districts && { ... districts , features : districts . features . filter ( f => f . id !== 0 ) } ; return new Promise ( ( resolve , reject ) => { axios ( { method : "POST" , data : Buffer . from ( JSON . stringify ( geojson ) ) , url : "http://api.planscore.org/upload/" , headers : { Authorization : `Bearer ${ planScoreToken }` } } ) . then ( response => { resolve ( response . data ) ; } ) . catch ( error => { reject ( error . message ) ; } ) ; } ) ; } @ Override ( ) @ UseGuards ( OptionalJwtAuthGuard ) async getOne ( @ Param ( "id" ) id , @ ParsedRequest ( ) req ) { return this . getProject ( req , id ) ; } @ Override ( ) @ UseGuards ( JwtAuthGuard ) getMany ( @ ParsedRequest ( ) req , @ Query ( "page" , ParseIntPipe ) page = 1 , @ Query ( "limit" , ParseIntPipe ) limit = 10 ) { const userId = req . parsed . authPersist . userId as string ; return this . service . findAllUserProjectsPaginated ( userId , { page , limit } ) ; } @ Override ( ) @ UseGuards ( JwtAuthGuard ) async updateOne ( @ Param ( "id" ) id , @ ParsedRequest ( ) req , @ ParsedBody ( ) dto ) { const userId = req . parsed . authPersist . userId as string ; const existingProject = await this . getProjectWithDistricts ( id , userId ) ; if ( dto . lockedDistricts && existingProject . numberOfDistricts !== dto . lockedDistricts . length ) { throw new BadRequestException ( { error : "Bad Request" , message : { lockedDistricts : [ `Length of array does not match "numberOfDistricts"` ] } } as Errors < UpdateProjectDto > ) ; } validateNumberOfMembers ( dto , existingProject . numberOfDistricts ) ; const staticMetadata = ( await this . getGeoUnitProperties ( existingProject . regionConfig . s3URI ) ) . staticMetadata ; const allowedDemographicFields = getDemographicsMetricFields ( staticMetadata ) . map ( ( [ , field ] ) => field ) ; const allowedVotingFields = getVotingMetricFields ( staticMetadata ) . map ( ( [ , field ] ) => field ) || [ ] ; if ( dto . pinnedMetricFields && dto . pinnedMetricFields . some ( field => ! ( CORE_METRIC_FIELDS . includes ( field ) || allowedDemographicFields . includes ( field ) || allowedVotingFields . includes ( field ) ) ) ) { throw new BadRequestException ( { error : "Bad Request" , message : { pinnedMetricFields : [ `Field not allowed in "pinnedMetricFields"` ] } } as Errors < UpdateProjectDto > ) ; } const dataWithDefinitions = existingProject && dto . districtsDefinition && ( ! existingProject . districts || existingProject . regionConfigVersion !== existingProject . regionConfig . version || ! _ . isEqual ( dto . districtsDefinition , existingProject . districtsDefinition ) ) ? { ... dto , districts : await this . getGeojson ( { ... existingProject , districtsDefinition : dto . districtsDefinition } ) , regionConfigVersion : existingProject . regionConfig . version } : dto ; const whitelistedFields = [ "districtsDefinition" , "name" ] ; const fields = whitelistedFields . filter ( field => field in dto ) ; const data = _ . isEqual ( _ . pick ( dataWithDefinitions , fields ) , _ . pick ( existingProject , fields ) ) ? { ... dataWithDefinitions } : { ... dataWithDefinitions , updatedDt : new Date ( ) } ; return this . service . updateOne ( req , { ... data , isFeatured : dto . visibility === ProjectVisibility . Private ? false : existingProject ?. isFeatured } ) ; } @ Override ( ) @ UseGuards ( JwtAuthGuard ) async createOne ( @ ParsedRequest ( ) req , @ ParsedBody ( ) dto ) { if ( dto . numberOfDistricts ) { validateNumberOfMembers ( dto , dto . numberOfDistricts ) ; } const findTemplate = ( id ) => this . templateService . findOne ( { id } , { relations : [ "regionConfig" , "referenceLayers" , "chamber" ] } ) ; const template = dto . projectTemplate ? await findTemplate ( dto . projectTemplate . id ) : undefined ; if ( dto . projectTemplate && ! template ) { throw new NotFoundException ( `Project template for id '${ dto . projectTemplate ?. id }' not found` ) ; } const userId = req . parsed . authPersist . userId as string ; const user = await this . usersService . findOne ( userId ) ; if ( ! user ) { throw new InternalServerErrorException ( `User not found for authenticated user id ${ userId }` ) ; } const chamber = dto . chamber ?. id ? await this . chambersService . findOne ( dto . chamber ?. id ) : undefined ; const regionConfig = dto . regionConfig ? await this . regionConfigService . findOne ( { id : dto . regionConfig . id } ) : template ? template . regionConfig : undefined ; if ( ! regionConfig ) { throw new NotFoundException ( `Unable to find region config: ${ dto . regionConfig ?. id }` ) ; } const geoCollection = await this . topologyService . get ( regionConfig . s3URI ) ; if ( ! geoCollection ) { throw new NotFoundException ( `Topology ${ regionConfig . s3URI } not found` , MakeDistrictsErrors . TOPOLOGY_NOT_FOUND ) ; } const templateFields = ( { name , regionConfig , chamber , numberOfDistricts , numberOfMembers , populationDeviation , pinnedMetricFields , districtsDefinition } ) => ( { name , regionConfig , chamber , numberOfDistricts , numberOfMembers , populationDeviation , pinnedMetricFields , districtsDefinition } ) ; const formdata = template ? { ... dto , ... templateFields ( template ) , districtsDefinition : dto . districtsDefinition || template . districtsDefinition } : dto ; if ( ! formdata . numberOfDistricts ) { throw new InternalServerErrorException ( ) ; } const data = this . formatCreateProjectDto ( formdata , geoCollection , regionConfig , req ) ; const districts = await this . getGeojson ( { numberOfDistricts : formdata . numberOfDistricts , districtsDefinition : data . districtsDefinition , user , chamber : template ?. chamber || chamber , regionConfig } ) ; try { const project = await this . service . createOne ( req , { ... data , districts } ) ; if ( template ) { for ( const refLayer of template . referenceLayers ) { void ( await this . referenceLayerService . create ( { name : refLayer . name , label_field : refLayer . label_field , layer : refLayer . layer , layer_type : refLayer . layer_type , project } ) ) ; } } return project ; } catch ( error ) { this . logger . error ( `Error creating project: ${ error }` ) ; throw new InternalServerErrorException ( ) ; } } }