Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "open-foris-arena",
"version": "2.5.9",
"version": "2.5.10",
"license": "MIT",
"engines": {
"node": "^24.11.1"
Expand Down Expand Up @@ -237,4 +237,4 @@
"{common,core,server,test,webapp}/**/*.{js,jsx,ts,tsx}": "eslint --cache --fix"
},
"packageManager": "yarn@4.17.1"
}
}
42 changes: 24 additions & 18 deletions server/modules/dataExport/api/dataExportApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Request from '@server/utils/request'

import * as AuthMiddleware from '@server/modules/auth/authApiMiddleware'
import { sendTempFileToResponse } from '@server/modules/fileDownload/api/fileDownloadApi'
import { requireRecordsMatchUserGroupQualifiers } from '@server/modules/record/api/recordQualifierMiddleware'
import * as SurveyService from '@server/modules/survey/service/surveyService'

import * as DataExportService from '../service/dataExportService'
Expand All @@ -18,25 +19,30 @@ const checkExportUuid = (exportUuid) => {
}

export const init = (app) => {
app.post('/survey/:surveyId/data-export', AuthMiddleware.requireRecordsExportPermission, async (req, res, next) => {
try {
const { surveyId, cycle, recordUuids, search, options } = Request.getParams(req)

const user = Request.getUser(req)

const job = DataExportService.startCsvDataExportJob({
user,
surveyId,
cycle,
recordUuids,
search,
options,
})
res.json({ job: JobUtils.jobToJSON(job) })
} catch (error) {
next(error)
app.post(
'/survey/:surveyId/data-export',
AuthMiddleware.requireRecordsExportPermission,
requireRecordsMatchUserGroupQualifiers,
async (req, res, next) => {
try {
const { surveyId, cycle, recordUuids, search, options } = Request.getParams(req)

const user = Request.getUser(req)

const job = DataExportService.startCsvDataExportJob({
user,
surveyId,
cycle,
recordUuids,
search,
options,
})
res.json({ job: JobUtils.jobToJSON(job) })
} catch (error) {
next(error)
}
}
})
)

app.post(
'/survey/:surveyId/data-summary-export',
Expand Down
3 changes: 2 additions & 1 deletion server/modules/record/api/recordApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,9 @@ export const init = (app) => {
app.get('/survey/:surveyId/records/summary/export', requireRecordListExportPermission, async (req, res, next) => {
try {
const { surveyId, cycle, fileFormat = FileFormats.xlsx } = Request.getParams(req)
const user = Request.getUser(req)

await RecordService.exportRecordsSummary({ res, surveyId, cycle, fileFormat })
await RecordService.exportRecordsSummary({ res, surveyId, cycle, fileFormat, user })
} catch (error) {
next(error)
}
Expand Down
13 changes: 6 additions & 7 deletions server/modules/record/api/recordQualifierMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,13 @@ const _recordMatchesUserGroupQualifiers = async ({ user, surveyId, recordUuid, p

const survey = await fetchSurveyByCycle(Record.getCycle(record))

const qualifierFilters = await RecordManager.fetchUserQualifierFilters({ user, survey })
const qualifierFilters = await SurveyManager.fetchUserQualifierFilters({ user, survey })

// apply the node value carried by the request (if any) before checking, so an edit that sets
// a qualifier attribute to a value outside the user's group qualifiers is rejected too, not just
// records that already mismatch before the edit is applied
const recordWithPendingNode = pendingNode ? Record.assocNode(pendingNode)(record) : record

return RecordManager.recordMatchesQualifierFilters({ survey, record: recordWithPendingNode, qualifierFilters })
// pendingNode is passed through rather than merged into the record via Record.assocNode: the record
// was fetched with fetchForUpdate: false, so its _nodesIndex hasn't been built, and assocNode would
// initialize one containing only pendingNode, shadowing every other already-persisted node (see
// recordMatchesQualifierFilters' jsdoc for details)
return RecordManager.recordMatchesQualifierFilters({ survey, record, qualifierFilters, pendingNode })
}

/**
Expand Down
100 changes: 27 additions & 73 deletions server/modules/record/manager/_recordManager/recordQualifierMatcher.js
Original file line number Diff line number Diff line change
@@ -1,96 +1,50 @@
import { Objects, ServiceRegistry } from '@openforis/arena-core'
import { ServerServiceType } from '@openforis/arena-server'
import { Objects } from '@openforis/arena-core'

import * as User from '@core/user/user'
import * as UserGroup from '@core/user/userGroup/userGroup'
import * as UserGroupQualifier from '@core/user/userGroup/userGroupQualifier'
import * as Survey from '@core/survey/survey'
import * as NodeDef from '@core/survey/nodeDef'
import * as CategoryItem from '@core/survey/categoryItem'
import * as Record from '@core/record/record'
import * as Node from '@core/record/node'
import { NodeValues } from '@core/record/nodeValues'

import { db } from '@server/db/db'
import { CategoryItemProviderDefault } from '@server/modules/category/manager/categoryItemProviderDefault'

/**
* Determines the qualifier attribute/value pairs that the given user's group restricts them to, if any.
* Mirrors the lookup used to auto-fill qualifier attributes when a new record is created
* (see _applyGroupQualifierValues in recordUpdateManager.js), so write-time and read-time
* restrictions stay consistent: only the user's first UserGroup in the survey is considered.
* For code attributes, the qualifier's code is resolved to its category item via a direct DB lookup
* (works regardless of category size, unlike the survey's category items ref data index, which
* excludes "big" categories), so the returned value can be compared against a record's node value by
* item uuid rather than relying on the ref data index being loaded.
* @param {object} params - The function parameters.
* @param {object} params.user - The current user.
* @param {object} params.survey - The survey, with node defs loaded.
* @param {pgPromise.IDatabase} [client] - The db client.
* @returns {Promise<Array<{nodeDef: object, value: *}>>} - An empty array when the user is
* unrestricted (no group, or group with no matching qualifiers), otherwise the list of qualifier
* filters to apply.
*/
export const fetchUserQualifierFilters = async ({ user, survey }, client = db) => {
const qualifierNodeDefs = Survey.getQualifierNodeDefs(survey)
if (qualifierNodeDefs.length === 0) return []

const userGroupService = ServiceRegistry.getInstance().getService(ServerServiceType.userGroup)
const userGroups = await userGroupService.getManyByUser(
{ userUuid: User.getUuid(user), surveyUuid: Survey.getUuid(survey) },
client
)
const userGroup = userGroups[0]
if (!userGroup) return []

const qualifiers = UserGroup.getQualifiers(userGroup)
if (qualifiers.length === 0) return []

const filters = []
for (const nodeDef of qualifierNodeDefs) {
const qualifier = qualifiers.find((q) => UserGroupQualifier.getName(q) === NodeDef.getName(nodeDef))
const qualifierValue = qualifier ? UserGroupQualifier.getValue(qualifier) : null
if (Objects.isEmpty(qualifierValue)) continue

if (NodeDef.isCode(nodeDef)) {
const categoryUuid = NodeDef.getCategoryUuid(nodeDef)
const item = await CategoryItemProviderDefault.getItemByCode(
{ survey, categoryUuid, code: qualifierValue },
client
)
if (!item) continue
filters.push({
nodeDef,
value: Node.newNodeValueCode({ itemUuid: CategoryItem.getUuid(item), code: qualifierValue }),
})
} else {
filters.push({ nodeDef, value: qualifierValue })
}
}
return filters
}

/**
* Checks whether a record's qualifier attribute values match the given qualifier filters.
* @param {object} params - The function parameters.
* @param {object} params.survey - The survey, with node defs and categories loaded.
* @param {object} params.record - The record (with nodes loaded) to check.
* @param {Array<{nodeDef: object, value: string}>} params.qualifierFilters - The qualifier filters,
* as returned by fetchUserQualifierFilters.
* as returned by SurveyManager.fetchUserQualifierFilters.
* @param {object} [params.pendingNode] - A node carried by the current request but not yet persisted
* (e.g. a create/update of some attribute), applied on top of the record's own nodes when checking, so
* an edit that sets a qualifier attribute to a value outside the user's group qualifiers is rejected
* too. Deliberately not merged into `record` itself via `Record.assocNode`: doing so on a record whose
* `_nodesIndex` hasn't been built (see `fetchForUpdate` in `fetchRecordAndNodesByUuid`) makes
* `assocNode` initialize a new index containing only this one node, which then shadows the full node
* list for every other lookup (`Record.getNodeChildrenByDefUuid` and friends trust a present index
* instead of falling back to a full scan), making every other already-persisted node - including the
* qualifier attribute - invisible.
* @returns {boolean} - True if qualifierFilters is empty (unrestricted), if the record has not been
* initialized yet (no nodes: checkin will auto-fill qualifier attributes to match, see
* _applyGroupQualifierValues in recordUpdateManager.js), or every filter matches the record's
* corresponding attribute value; false otherwise.
* initialized yet (no nodes), if a qualifier attribute node is missing or not yet given a value
* (record creation will auto-fill qualifier attributes to match, see _applyGroupQualifierValues in
* recordUpdateManager.js, but that happens as one of the last steps of record creation, so a
* just-created record may briefly have the node without its value yet), or every filter matches the
* record's corresponding attribute value; false only when a qualifier attribute has an actual value
* that differs from the expected one.
*/
export const recordMatchesQualifierFilters = ({ survey, record, qualifierFilters }) => {
export const recordMatchesQualifierFilters = ({ survey, record, qualifierFilters, pendingNode = null }) => {
if (qualifierFilters.length === 0) return true
if (Record.getNodesArray(record).length === 0) return true

const rootNode = Record.getRootNode(record)

return qualifierFilters.every(({ nodeDef, value }) => {
const node = Record.getNodeChildrenByDefUuid(rootNode, NodeDef.getUuid(nodeDef))(record)[0]
if (!node) return false
const nodeDefUuid = NodeDef.getUuid(nodeDef)
const node =
pendingNode && Node.getNodeDefUuid(pendingNode) === nodeDefUuid
? pendingNode
: Record.getNodeChildByDefUuid(rootNode, nodeDefUuid)(record)
// node missing or not yet given a value: record is still being initialized (the qualifier auto-fill
// is one of the last steps of record creation), so this is not (yet) evidence of a cross-group
// mismatch; only a value that has actually been set and differs should be rejected
if (!node || Objects.isEmpty(Node.getValue(node))) return true
return NodeValues.isValueEqual({ survey, nodeDef, value: Node.getValue(node), valueSearch: value })
Comment thread
SteRiccio marked this conversation as resolved.
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import * as RecordFileManager from '@server/modules/record/manager/recordFileMan
import * as NodeDefRepository from '@server/modules/nodeDef/repository/nodeDefRepository'
import * as DataTableUpdateRepository from '@server/modules/surveyRdb/repository/dataTableUpdateRepository'
import * as DataTableReadRepository from '@server/modules/surveyRdb/repository/dataTableReadRepository'
import * as SurveyManager from '@server/modules/survey/manager/surveyManager'

import * as RecordQualifierMatcher from './recordQualifierMatcher'
import * as RecordValidationManager from './recordValidationManager'
import * as NodeCreationManager from './nodeCreationManager'
import * as NodeUpdateManager from './nodeUpdateManager'
Expand Down Expand Up @@ -79,7 +79,7 @@ const _applyGroupQualifierValues = async (
{ user, survey, record, timezoneOffset, nodesUpdateListener, nodesValidationListener },
client
) => {
const qualifierFilters = await RecordQualifierMatcher.fetchUserQualifierFilters({ user, survey }, client)
const qualifierFilters = await SurveyManager.fetchUserQualifierFilters({ user, survey }, client)
if (qualifierFilters.length === 0) return record

const rootNode = Record.getRootNode(record)
Expand Down
9 changes: 3 additions & 6 deletions server/modules/record/manager/recordManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import * as UserManager from '@server/modules/user/manager/userManager'
import * as RecordRepository from '../repository/recordRepository'
import * as FileRepository from '../repository/fileRepository'
import * as NodeRepository from '../repository/nodeRepository'
import * as RecordQualifierMatcher from './_recordManager/recordQualifierMatcher'
import * as RecordUpdateManager from './_recordManager/recordUpdateManager'
import { NodeRdbManager } from './_recordManager/nodeRDBManager'

Expand Down Expand Up @@ -78,7 +77,7 @@ export const fetchRecordsSummaryBySurveyId = async (
summaryDefs = Survey.getRootSummaryDefs({ cycle })(survey)
nodeDefKeys = Survey.getNodeDefRootKeysSorted({ cycle })(survey)
if (user) {
qualifierNodeDefFilters = await RecordQualifierMatcher.fetchUserQualifierFilters({ user, survey }, client)
qualifierNodeDefFilters = await SurveyManager.fetchUserQualifierFilters({ user, survey }, client)
}
}

Expand Down Expand Up @@ -160,9 +159,7 @@ export const countRecordsBySurveyId = async (
const survey = await SurveyManager.fetchSurveyAndNodeDefsBySurveyId({ surveyId, cycle, draft: nodeDefsDraft }, client)
const nodeDefKeys = Survey.getNodeDefRootKeys(survey)
const summaryDefs = Survey.getRootSummaryDefs({ cycle })(survey)
const qualifierNodeDefFilters = user
? await RecordQualifierMatcher.fetchUserQualifierFilters({ user, survey }, client)
: []
const qualifierNodeDefFilters = user ? await SurveyManager.fetchUserQualifierFilters({ user, survey }, client) : []

return RecordRepository.countRecordsBySurveyId(
{ surveyId, cycle, search, nodeDefKeys, summaryDefs, nodeDefRoot, ownerUuid, qualifierNodeDefFilters },
Expand Down Expand Up @@ -262,7 +259,7 @@ export const fetchRecordAndNodesByUuid = async (

export { fetchNodeByUuid, fetchChildNodesByNodeDefUuids } from '../repository/nodeRepository'

export { fetchUserQualifierFilters, recordMatchesQualifierFilters } from './_recordManager/recordQualifierMatcher'
export { recordMatchesQualifierFilters } from './_recordManager/recordQualifierMatcher'

const fetchNodeRefData = async ({ survey, node, isCode }, client) => {
const surveyId = Survey.getId(survey)
Expand Down
3 changes: 2 additions & 1 deletion server/modules/record/repository/recordRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,8 @@ export const fetchRecordsSummaryBySurveyId = async (
const paramName = `qualifierValue${index}`
const colName = NodeDefTable.getColumnName(nodeDef)
recordsSelectWhereConditions.push(`"${colName}" = $/${paramName}/`)
qualifierFilterParams[paramName] = value
const columnValue = NodeDef.isCode(nodeDef) ? value?.code : value
qualifierFilterParams[paramName] = columnValue
})

const whereConditionsJoint = recordsSelectWhereConditions.map((condition) => `(${condition})`).join(' AND ')
Expand Down
3 changes: 2 additions & 1 deletion server/modules/record/service/recordService.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,12 @@ export const {
updateRecordOwner,
} = RecordManager

export const exportRecordsSummary = async ({ res, surveyId, cycle, fileFormat }) => {
export const exportRecordsSummary = async ({ res, surveyId, cycle, fileFormat, user }) => {
const { list, nodeDefKeys } = await RecordManager.fetchRecordsSummaryBySurveyId({
surveyId,
cycle,
includeCounts: true,
user,
})

const valueFormattersByType = {
Expand Down
2 changes: 2 additions & 0 deletions server/modules/survey/manager/surveyManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -599,3 +599,5 @@ export const deleteAllActivityLog = async ({ surveyId }, client = db) =>
ActivityLogRepository.deleteAll({ surveyId }, client)

export const { dropSurveySchema } = SurveyRepository

export { fetchUserQualifierFilters } from './surveyUserGroupQualifierFilters'
69 changes: 69 additions & 0 deletions server/modules/survey/manager/surveyUserGroupQualifierFilters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Objects, ServiceRegistry } from '@openforis/arena-core'
import { ServerServiceType } from '@openforis/arena-server'

import * as User from '@core/user/user'
import * as UserGroup from '@core/user/userGroup/userGroup'
import * as UserGroupQualifier from '@core/user/userGroup/userGroupQualifier'
import * as Survey from '@core/survey/survey'
import * as NodeDef from '@core/survey/nodeDef'
import * as CategoryItem from '@core/survey/categoryItem'
import * as Node from '@core/record/node'

import { db } from '@server/db/db'
import { CategoryItemProviderDefault } from '@server/modules/category/manager/categoryItemProviderDefault'

/**
* Determines the qualifier attribute/value pairs that the given user's group restricts them to, if any.
* Mirrors the lookup used to auto-fill qualifier attributes when a new record is created
* (see _applyGroupQualifierValues in recordUpdateManager.js), so write-time and read-time
* restrictions stay consistent: only the user's first UserGroup in the survey is considered.
* For code attributes, the qualifier's code is resolved to its category item via a direct DB lookup
* (works regardless of category size, unlike the survey's category items ref data index, which
* excludes "big" categories), so the returned value can be compared against a record's node value by
* item uuid rather than relying on the ref data index being loaded.
* @param {object} params - The function parameters.
* @param {object} params.user - The current user.
* @param {object} params.survey - The survey, with node defs loaded.
* @param {pgPromise.IDatabase} [client] - The db client.
* @returns {Promise<Array<{nodeDef: object, value: *}>>} - An empty array when the user is
* unrestricted (no group, or group with no matching qualifiers), otherwise the list of qualifier
* filters to apply.
*/
export const fetchUserQualifierFilters = async ({ user, survey }, client = db) => {
const qualifierNodeDefs = Survey.getQualifierNodeDefs(survey)
if (qualifierNodeDefs.length === 0) return []

const userGroupService = ServiceRegistry.getInstance().getService(ServerServiceType.userGroup)
const userGroups = await userGroupService.getManyByUser(
{ userUuid: User.getUuid(user), surveyUuid: Survey.getUuid(survey) },
client
)
const userGroup = userGroups[0]
if (!userGroup) return []

const qualifiers = UserGroup.getQualifiers(userGroup)
if (qualifiers.length === 0) return []

const filters = []
for (const nodeDef of qualifierNodeDefs) {
const qualifier = qualifiers.find((q) => UserGroupQualifier.getName(q) === NodeDef.getName(nodeDef))
const qualifierValue = qualifier ? UserGroupQualifier.getValue(qualifier) : null
if (Objects.isEmpty(qualifierValue)) continue

if (NodeDef.isCode(nodeDef)) {
const categoryUuid = NodeDef.getCategoryUuid(nodeDef)
const item = await CategoryItemProviderDefault.getItemByCode(
{ survey, categoryUuid, code: qualifierValue },
client
)
if (!item) continue
filters.push({
nodeDef,
value: Node.newNodeValueCode({ itemUuid: CategoryItem.getUuid(item), code: qualifierValue }),
})
} else {
filters.push({ nodeDef, value: qualifierValue })
}
}
return filters
}
Loading
Loading