diff --git a/src/api/api.ts b/src/api/api.ts index 4fc7a2d0..2508315e 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -8,6 +8,7 @@ import { DataQueryApi } from './dataQuery' import { InfoApi } from './info' import { MessageApi } from './message' import { User2FAAuthApi } from './user2FAAuth' +import { UserGroupApi } from './userGroup' export const Api: ExpressInitializer = { init: (express: Express): void => { @@ -17,5 +18,6 @@ export const Api: ExpressInitializer = { InfoApi.init(express) MessageApi.init(express) User2FAAuthApi.init(express) + UserGroupApi.init(express) }, } diff --git a/src/api/endpoint/index.ts b/src/api/endpoint/index.ts index e3230bdc..f6263088 100644 --- a/src/api/endpoint/index.ts +++ b/src/api/endpoint/index.ts @@ -4,6 +4,7 @@ import { dataQuery } from './dataQuery' import { info } from './info' import { message } from './message' import { user2FAAuth } from './user2FA' +import { userGroup } from './userGroup' export const ApiEndpoint = { auth, @@ -12,4 +13,5 @@ export const ApiEndpoint = { info, message, user2FAAuth, + userGroup, } diff --git a/src/api/endpoint/userGroup.ts b/src/api/endpoint/userGroup.ts new file mode 100644 index 00000000..96eaab5f --- /dev/null +++ b/src/api/endpoint/userGroup.ts @@ -0,0 +1,13 @@ +import { getApiPathSurvey } from './common' + +const moduleName = 'user-groups' + +export const userGroup = { + userGroups: (surveyId: string): string => getApiPathSurvey(surveyId, moduleName), + userGroupsCount: (surveyId: string): string => getApiPathSurvey(surveyId, moduleName, 'count'), + userGroup: (surveyId: string, groupUuid: string): string => getApiPathSurvey(surveyId, moduleName, groupUuid), + userGroupMembers: (surveyId: string, groupUuid: string): string => + getApiPathSurvey(surveyId, moduleName, groupUuid, 'members'), + userGroupMember: (surveyId: string, groupUuid: string, userUuid: string): string => + getApiPathSurvey(surveyId, moduleName, groupUuid, 'members', userUuid), +} diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts index 31a3200e..f1893909 100644 --- a/src/api/middleware/auth.ts +++ b/src/api/middleware/auth.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from 'express' import { Authorizer, + AuthGroupName, DownloadAuthTokenPayload, RecordService, ServiceRegistry, @@ -112,6 +113,18 @@ const requireUserPermission = } } +// Only the survey's `surveyAdmin` group (or system admins) may manage user-groups. +// Deliberately checks the auth-group's name rather than its stored `permissions` +// column: AuthGroups.getPermissions() (arena-core) derives permissions from a +// static, non-exported map keyed by group name, not from that DB column. +const canManageUserGroups = (user: User, surveyInfo?: any) => { + if (!user) return false + if (Users.isSystemAdmin(user)) return true + if (!surveyInfo) return false + const authGroup = Users.getAuthGroupBySurveyUuid(surveyInfo.uuid)(user) + return authGroup?.name === AuthGroupName.surveyAdmin +} + const requireLoggedInUser = async (req: Request, _res: Response, next: NextFunction) => { try { const user = Requests.getUser(req) @@ -196,5 +209,8 @@ export const ApiAuthMiddleware = { requireCanViewAccessRequestsPermission: requirePermission(Authorizer.canViewUsersAccessRequests), requireCanEditAccessRequestsPermission: requirePermission(Authorizer.canEditUsersAccessRequests), + // User groups + requireUserGroupManagePermission: requireSurveyPermission(canManageUserGroups), + requireDownloadToken, } diff --git a/src/api/userGroup/common.ts b/src/api/userGroup/common.ts new file mode 100644 index 00000000..c3489716 --- /dev/null +++ b/src/api/userGroup/common.ts @@ -0,0 +1,37 @@ +import { ServiceRegistry, ServiceType, SurveyService } from '@openforis/arena-core' + +import { ServerError, ServerErrorCode } from '../../server' +import { ServerServiceType } from '../../server/arenaServer/serverServiceType' +import { UserGroup } from '../../repository/userGroup' +import { UserGroupService } from '../../service' + +export const getUserGroupService = (): UserGroupService => + ServiceRegistry.getInstance().getService(ServerServiceType.userGroup) as UserGroupService + +export const getSurveyUuid = async (surveyId: number): Promise => { + const surveyService = ServiceRegistry.getInstance().getService(ServiceType.survey) as SurveyService + const survey = await surveyService.get({ surveyId }) + return survey.uuid +} + +/** + * Loads the user group by uuid and ensures it belongs to the survey identified by surveyId. + * This prevents a survey admin from acting on a user group belonging to a different survey + * just by guessing/knowing its uuid. + */ +export const getUserGroupBelongingToSurvey = async (params: { + surveyId: number + groupUuid: string +}): Promise<{ surveyUuid: string; userGroup: UserGroup }> => { + const { surveyId, groupUuid } = params + + const surveyUuid = await getSurveyUuid(surveyId) + + const service = getUserGroupService() + const userGroup = await service.getByUuid({ uuid: groupUuid }) + + if (userGroup?.surveyUuid !== surveyUuid) { + throw new ServerError('appErrors.userGroup.notFound', { groupUuid }, ServerErrorCode.NOT_FOUND) + } + return { surveyUuid, userGroup } +} diff --git a/src/api/userGroup/create.ts b/src/api/userGroup/create.ts new file mode 100644 index 00000000..725e10df --- /dev/null +++ b/src/api/userGroup/create.ts @@ -0,0 +1,54 @@ +import { Express } from 'express' + +import { ExpressInitializer } from '../../server' +import { Requests } from '../../utils' + +import { ApiEndpoint } from '../endpoint' +import { ApiAuthMiddleware } from '../middleware' +import { getSurveyUuid, getUserGroupBelongingToSurvey, getUserGroupService } from './common' + +const { requireUserGroupManagePermission } = ApiAuthMiddleware + +export const UserGroupCreate: ExpressInitializer = { + init: (express: Express): void => { + express.post( + ApiEndpoint.userGroup.userGroups(':surveyId'), + requireUserGroupManagePermission, + async (req, res, next) => { + try { + const { surveyId } = Requests.getParams(req) + const item = req.body + + const surveyUuid = await getSurveyUuid(surveyId) + + const service = getUserGroupService() + const userGroupInserted = await service.insert({ surveyUuid, item }) + + res.json(userGroupInserted) + } catch (error) { + next(error) + } + } + ) + + express.post( + ApiEndpoint.userGroup.userGroupMembers(':surveyId', ':groupUuid'), + requireUserGroupManagePermission, + async (req, res, next) => { + try { + const { surveyId, groupUuid } = Requests.getParams(req) + const { userUuid } = req.body + + await getUserGroupBelongingToSurvey({ surveyId, groupUuid }) + + const service = getUserGroupService() + await service.addMember({ groupUuid, userUuid }) + + res.json({ groupUuid, userUuid }) + } catch (error) { + next(error) + } + } + ) + }, +} diff --git a/src/api/userGroup/delete.ts b/src/api/userGroup/delete.ts new file mode 100644 index 00000000..c51166ca --- /dev/null +++ b/src/api/userGroup/delete.ts @@ -0,0 +1,52 @@ +import { Express } from 'express' + +import { ExpressInitializer } from '../../server' +import { Requests } from '../../utils' + +import { ApiEndpoint } from '../endpoint' +import { ApiAuthMiddleware } from '../middleware' +import { getUserGroupBelongingToSurvey, getUserGroupService } from './common' + +const { requireUserGroupManagePermission } = ApiAuthMiddleware + +export const UserGroupDelete: ExpressInitializer = { + init: (express: Express): void => { + express.delete( + ApiEndpoint.userGroup.userGroup(':surveyId', ':groupUuid'), + requireUserGroupManagePermission, + async (req, res, next) => { + try { + const { surveyId, groupUuid } = Requests.getParams(req) + + await getUserGroupBelongingToSurvey({ surveyId, groupUuid }) + + const service = getUserGroupService() + const userGroupDeleted = await service.deleteItem({ uuid: groupUuid }) + + res.json(userGroupDeleted) + } catch (error) { + next(error) + } + } + ) + + express.delete( + ApiEndpoint.userGroup.userGroupMember(':surveyId', ':groupUuid', ':userUuid'), + requireUserGroupManagePermission, + async (req, res, next) => { + try { + const { surveyId, groupUuid, userUuid } = Requests.getParams(req) + + await getUserGroupBelongingToSurvey({ surveyId, groupUuid }) + + const service = getUserGroupService() + await service.removeMember({ groupUuid, userUuid }) + + res.json({ groupUuid, userUuid }) + } catch (error) { + next(error) + } + } + ) + }, +} diff --git a/src/api/userGroup/index.ts b/src/api/userGroup/index.ts new file mode 100644 index 00000000..6b9e7a90 --- /dev/null +++ b/src/api/userGroup/index.ts @@ -0,0 +1,16 @@ +import { Express } from 'express' + +import { ExpressInitializer } from '../../server' +import { UserGroupCreate } from './create' +import { UserGroupRead } from './read' +import { UserGroupUpdate } from './update' +import { UserGroupDelete } from './delete' + +export const UserGroupApi: ExpressInitializer = { + init: (express: Express): void => { + UserGroupCreate.init(express) + UserGroupRead.init(express) + UserGroupUpdate.init(express) + UserGroupDelete.init(express) + }, +} diff --git a/src/api/userGroup/read.ts b/src/api/userGroup/read.ts new file mode 100644 index 00000000..0de4bacf --- /dev/null +++ b/src/api/userGroup/read.ts @@ -0,0 +1,81 @@ +import { Express } from 'express' + +import { ExpressInitializer } from '../../server' +import { Requests } from '../../utils' + +import { ApiEndpoint } from '../endpoint' +import { ApiAuthMiddleware } from '../middleware' +import { getSurveyUuid, getUserGroupBelongingToSurvey, getUserGroupService } from './common' + +const { requireSurveyViewPermission } = ApiAuthMiddleware + +export const UserGroupRead: ExpressInitializer = { + init: (express: Express): void => { + express.get( + ApiEndpoint.userGroup.userGroupsCount(':surveyId'), + requireSurveyViewPermission, + async (req, res, next) => { + try { + const { surveyId } = Requests.getParams(req) + const surveyUuid = await getSurveyUuid(surveyId) + + const service = getUserGroupService() + const count = await service.count({ surveyUuid }) + + res.json({ count }) + } catch (error) { + next(error) + } + } + ) + + express.get(ApiEndpoint.userGroup.userGroups(':surveyId'), requireSurveyViewPermission, async (req, res, next) => { + try { + const { surveyId } = Requests.getParams(req) + const surveyUuid = await getSurveyUuid(surveyId) + + const service = getUserGroupService() + const list = await service.getAll({ surveyUuid }) + + res.json({ list }) + } catch (error) { + next(error) + } + }) + + express.get( + ApiEndpoint.userGroup.userGroup(':surveyId', ':groupUuid'), + requireSurveyViewPermission, + async (req, res, next) => { + try { + const { surveyId, groupUuid } = Requests.getParams(req) + + const { userGroup } = await getUserGroupBelongingToSurvey({ surveyId, groupUuid }) + + res.json(userGroup) + } catch (error) { + next(error) + } + } + ) + + express.get( + ApiEndpoint.userGroup.userGroupMembers(':surveyId', ':groupUuid'), + requireSurveyViewPermission, + async (req, res, next) => { + try { + const { surveyId, groupUuid } = Requests.getParams(req) + + await getUserGroupBelongingToSurvey({ surveyId, groupUuid }) + + const service = getUserGroupService() + const list = await service.getMembers({ groupUuid }) + + res.json({ list }) + } catch (error) { + next(error) + } + } + ) + }, +} diff --git a/src/api/userGroup/update.ts b/src/api/userGroup/update.ts new file mode 100644 index 00000000..ef297346 --- /dev/null +++ b/src/api/userGroup/update.ts @@ -0,0 +1,34 @@ +import { Express } from 'express' + +import { ExpressInitializer } from '../../server' +import { Requests } from '../../utils' + +import { ApiEndpoint } from '../endpoint' +import { ApiAuthMiddleware } from '../middleware' +import { getUserGroupBelongingToSurvey, getUserGroupService } from './common' + +const { requireUserGroupManagePermission } = ApiAuthMiddleware + +export const UserGroupUpdate: ExpressInitializer = { + init: (express: Express): void => { + express.put( + ApiEndpoint.userGroup.userGroup(':surveyId', ':groupUuid'), + requireUserGroupManagePermission, + async (req, res, next) => { + try { + const { surveyId, groupUuid } = Requests.getParams(req) + const { props } = req.body + + await getUserGroupBelongingToSurvey({ surveyId, groupUuid }) + + const service = getUserGroupService() + const userGroupUpdated = await service.update({ uuid: groupUuid, props }) + + res.json(userGroupUpdated) + } catch (error) { + next(error) + } + } + ) + }, +} diff --git a/src/db/dbMigrator/migration/public/migrations/20260713124754-create-user-group-tables.js b/src/db/dbMigrator/migration/public/migrations/20260713124754-create-user-group-tables.js new file mode 100644 index 00000000..5540d62a --- /dev/null +++ b/src/db/dbMigrator/migration/public/migrations/20260713124754-create-user-group-tables.js @@ -0,0 +1,45 @@ +'use strict' + +var dbm +var type +var seed +var fs = require('fs') +var path = require('path') +var Promise + +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate + type = dbm.dataType + seed = seedLink + Promise = options.Promise +} + +exports.up = function (db) { + var filePath = path.join(__dirname, 'sqls', '20260713124754-create-user-group-tables-up.sql') + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports.down = function (db) { + var filePath = path.join(__dirname, 'sqls', '20260713124754-create-user-group-tables-down.sql') + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports._meta = { + version: 1, +} diff --git a/src/db/dbMigrator/migration/public/migrations/sqls/20260713124754-create-user-group-tables-down.sql b/src/db/dbMigrator/migration/public/migrations/sqls/20260713124754-create-user-group-tables-down.sql new file mode 100644 index 00000000..13b8a4c2 --- /dev/null +++ b/src/db/dbMigrator/migration/public/migrations/sqls/20260713124754-create-user-group-tables-down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS user_group_user; +DROP TABLE IF EXISTS user_group; diff --git a/src/db/dbMigrator/migration/public/migrations/sqls/20260713124754-create-user-group-tables-up.sql b/src/db/dbMigrator/migration/public/migrations/sqls/20260713124754-create-user-group-tables-up.sql new file mode 100644 index 00000000..983e11b6 --- /dev/null +++ b/src/db/dbMigrator/migration/public/migrations/sqls/20260713124754-create-user-group-tables-up.sql @@ -0,0 +1,25 @@ +CREATE TABLE + user_group +( + uuid uuid NOT NULL DEFAULT uuid_generate_v4(), + survey_uuid uuid NULL REFERENCES survey (uuid) ON DELETE CASCADE, + props jsonb NOT NULL DEFAULT '{}'::jsonb, + date_created TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + date_modified TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + PRIMARY KEY (uuid) +); + +CREATE TABLE + user_group_user +( + user_uuid uuid REFERENCES "user" (uuid) ON DELETE CASCADE, + group_uuid uuid REFERENCES "user_group" (uuid) ON DELETE CASCADE, + props jsonb NOT NULL DEFAULT '{}'::jsonb, + date_created TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + date_modified TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + PRIMARY KEY (user_uuid, group_uuid) +); + +CREATE INDEX IF NOT EXISTS user_group_survey_idx ON user_group(survey_uuid); +CREATE INDEX IF NOT EXISTS user_group_user_user_idx ON user_group_user(user_uuid); +CREATE INDEX IF NOT EXISTS user_group_user_group_idx ON user_group_user(group_uuid); diff --git a/src/db/index.ts b/src/db/index.ts index 376912ea..2772a51d 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -29,4 +29,6 @@ export { TableUser2FADevice, TableAuthGroup, TableAuthGroupUser, + TableUserGroup, + TableUserGroupUser, } from './table' diff --git a/src/db/table/index.ts b/src/db/table/index.ts index 14a29769..714ef738 100644 --- a/src/db/table/index.ts +++ b/src/db/table/index.ts @@ -11,6 +11,8 @@ export { TableUser2FADevice, TableAuthGroup, TableAuthGroupUser, + TableUserGroup, + TableUserGroupUser, } from './schemaPublic' export { diff --git a/src/db/table/schemaPublic/index.ts b/src/db/table/schemaPublic/index.ts index 93a0dc7c..fc2859a9 100644 --- a/src/db/table/schemaPublic/index.ts +++ b/src/db/table/schemaPublic/index.ts @@ -9,3 +9,5 @@ export { TableUserTempAuthToken } from './userTempAuthToken' export { TableUserRefreshToken } from './userRefreshToken' export { TableUserResetPassword } from './userResetPassword' export { TableUser2FADevice } from './user2FADevice' +export { TableUserGroup } from './userGroup' +export { TableUserGroupUser } from './userGroupUser' diff --git a/src/db/table/schemaPublic/userGroup.ts b/src/db/table/schemaPublic/userGroup.ts new file mode 100644 index 00000000..d8ceafc4 --- /dev/null +++ b/src/db/table/schemaPublic/userGroup.ts @@ -0,0 +1,18 @@ +import { Column, ColumnType } from '../../column' +import { TableSchemaPublic } from './tableSchemaPublic' + +export class TableUserGroup extends TableSchemaPublic { + readonly uuid: Column = new Column(this, 'uuid', ColumnType.uuid) + readonly surveyUuid: Column = new Column(this, 'survey_uuid', ColumnType.uuid) + readonly props: Column = new Column(this, 'props', ColumnType.jsonb) + readonly dateCreated: Column = new Column(this, 'date_created', ColumnType.timeStamp) + readonly dateModified: Column = new Column(this, 'date_modified', ColumnType.timeStamp) + + constructor() { + super('user_group') + } + + get summaryColumns(): Column[] { + return [this.uuid, this.surveyUuid, this.props, this.dateCreated, this.dateModified] + } +} diff --git a/src/db/table/schemaPublic/userGroupUser.ts b/src/db/table/schemaPublic/userGroupUser.ts new file mode 100644 index 00000000..d20d05d9 --- /dev/null +++ b/src/db/table/schemaPublic/userGroupUser.ts @@ -0,0 +1,18 @@ +import { Column, ColumnType } from '../../column' +import { TableSchemaPublic } from './tableSchemaPublic' + +export class TableUserGroupUser extends TableSchemaPublic { + readonly userUuid: Column = new Column(this, 'user_uuid', ColumnType.uuid) + readonly groupUuid: Column = new Column(this, 'group_uuid', ColumnType.uuid) + readonly props: Column = new Column(this, 'props', ColumnType.jsonb) + readonly dateCreated: Column = new Column(this, 'date_created', ColumnType.timeStamp) + readonly dateModified: Column = new Column(this, 'date_modified', ColumnType.timeStamp) + + constructor() { + super('user_group_user') + } + + get summaryColumns(): Column[] { + return [this.userUuid, this.groupUuid, this.props, this.dateCreated, this.dateModified] + } +} diff --git a/src/index.ts b/src/index.ts index fd36b32c..2b819ab3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,8 @@ export { TableUserResetPassword, TableAuthGroup, TableAuthGroupUser, + TableUserGroup, + TableUserGroupUser, } from './db' export type { BaseProtocol } from './db' diff --git a/src/repository/index.ts b/src/repository/index.ts index b4f5ff76..410fb384 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -9,3 +9,5 @@ export { User2FADeviceRepository } from './user2FADevice' export { UserRefreshTokenRepository } from './userRefreshToken' export { UserResetPasswordRepository } from './userResetPassword' export { RecordRepository } from './record' +export { UserGroupRepository } from './userGroup' +export type { UserGroup, UserGroupProps, UserGroupQualifier, UserGroupMember } from './userGroup' diff --git a/src/repository/userGroup/delete.ts b/src/repository/userGroup/delete.ts new file mode 100644 index 00000000..9c863b53 --- /dev/null +++ b/src/repository/userGroup/delete.ts @@ -0,0 +1,16 @@ +import { BaseProtocol, DB, DBs, SqlDeleteBuilder, TableUserGroup } from '../../db' +import { UserGroup } from './types' + +export const deleteItem = (params: { uuid: string }, client: BaseProtocol = DB): Promise => { + const { uuid } = params + const table = new TableUserGroup() + const values = { [table.uuid.columnName]: uuid } + + const sql = new SqlDeleteBuilder() + .deleteFrom(table) + .where(values) + .returning(...table.summaryColumns) + .build() + + return client.oneOrNone(sql, values, (row) => DBs.transformCallback({ row })) +} diff --git a/src/repository/userGroup/getUserGroupsByUser.ts b/src/repository/userGroup/getUserGroupsByUser.ts new file mode 100644 index 00000000..b0232375 --- /dev/null +++ b/src/repository/userGroup/getUserGroupsByUser.ts @@ -0,0 +1,41 @@ +import { Objects } from '@openforis/arena-core' +import { BaseProtocol, DB, SqlJoinBuilder, SqlSelectBuilder, TableUserGroup, TableUserGroupUser } from '../../db' +import { UserGroup } from './types' + +export const getManyByUser = ( + options: { userUuid: string; surveyUuid?: string }, + client: BaseProtocol = DB +): Promise => { + const { userUuid, surveyUuid } = options + if (!userUuid) throw new Error(`missingParams, ${JSON.stringify(options)}`) + + const tableUserGroup = new TableUserGroup() + const tableUserGroupUser = new TableUserGroupUser() + + const joinClause = new SqlJoinBuilder() + .join(tableUserGroup) + .on(`${tableUserGroup.uuid} = ${tableUserGroupUser.groupUuid}`) + + const selectBuilder = new SqlSelectBuilder() + .select( + tableUserGroup.uuid, + tableUserGroup.surveyUuid, + tableUserGroup.props, + tableUserGroup.dateCreated, + tableUserGroup.dateModified + ) + .from(tableUserGroupUser) + .join(joinClause) + .where(`${tableUserGroupUser.userUuid} = $1`) + + const params: Array = [userUuid] + + if (surveyUuid) { + selectBuilder.where(`${tableUserGroup.surveyUuid} = $2`) + params.push(surveyUuid) + } + + const sql = selectBuilder.build() + + return client.map(sql, params, (row) => Objects.camelize(row, { limitToLevel: 1 })) +} diff --git a/src/repository/userGroup/index.ts b/src/repository/userGroup/index.ts new file mode 100644 index 00000000..20d80132 --- /dev/null +++ b/src/repository/userGroup/index.ts @@ -0,0 +1,23 @@ +import { insert } from './insert' +import { count, getAll, getByUuid } from './read' +import { update, updateProps } from './update' +import { deleteItem } from './delete' +import { getManyByUser } from './getUserGroupsByUser' +import { addMember, removeMember, getMembers } from './members' + +export const UserGroupRepository = { + count, + getAll, + getByUuid, + getManyByUser, + insert, + update, + updateProps, + deleteItem, + addMember, + removeMember, + getMembers, +} + +export type { UserGroup, UserGroupProps, UserGroupQualifier } from './types' +export type { UserGroupMember } from './members' diff --git a/src/repository/userGroup/insert.ts b/src/repository/userGroup/insert.ts new file mode 100644 index 00000000..3162d243 --- /dev/null +++ b/src/repository/userGroup/insert.ts @@ -0,0 +1,24 @@ +import { BaseProtocol, DB, DBs, SqlInsertBuilder, TableUserGroup } from '../../db' +import { UserGroup } from './types' + +export const insert = ( + params: { surveyUuid: string; item: Partial }, + client: BaseProtocol = DB +): Promise => { + const { surveyUuid, item } = params + if (!surveyUuid) throw new Error(`missingParams, ${JSON.stringify(params)}`) + + const table = new TableUserGroup() + const values = { + [table.surveyUuid.columnName]: surveyUuid, + [table.props.columnName]: item.props ?? {}, + } + + const sql = new SqlInsertBuilder() + .insertInto(table) + .valuesByColumn(values) + .returning(...table.summaryColumns) + .build() + + return client.one(sql, values, (row) => DBs.transformCallback({ row })) +} diff --git a/src/repository/userGroup/members.ts b/src/repository/userGroup/members.ts new file mode 100644 index 00000000..fb7cf479 --- /dev/null +++ b/src/repository/userGroup/members.ts @@ -0,0 +1,62 @@ +import { Objects } from '@openforis/arena-core' +import { + BaseProtocol, + DB, + SqlDeleteBuilder, + SqlInsertBuilder, + SqlJoinBuilder, + SqlSelectBuilder, + TableUser, + TableUserGroupUser, +} from '../../db' + +export interface UserGroupMember { + uuid: string + name: string + email: string +} + +export const addMember = ( + params: { groupUuid: string; userUuid: string }, + client: BaseProtocol = DB +): Promise => { + const { groupUuid, userUuid } = params + const table = new TableUserGroupUser() + const values = { + [table.groupUuid.columnName]: groupUuid, + [table.userUuid.columnName]: userUuid, + } + const sql = new SqlInsertBuilder().insertInto(table).valuesByColumn(values).build() + return client.none(sql, values) +} + +export const removeMember = ( + params: { groupUuid: string; userUuid: string }, + client: BaseProtocol = DB +): Promise => { + const { groupUuid, userUuid } = params + const table = new TableUserGroupUser() + const values = { + [table.groupUuid.columnName]: groupUuid, + [table.userUuid.columnName]: userUuid, + } + const sql = new SqlDeleteBuilder().deleteFrom(table).where(values).build() + return client.none(sql, values) +} + +export const getMembers = (params: { groupUuid: string }, client: BaseProtocol = DB): Promise => { + const { groupUuid } = params + const tableUser = new TableUser() + const tableUserGroupUser = new TableUserGroupUser() + + const joinClause = new SqlJoinBuilder().join(tableUser).on(`${tableUser.uuid} = ${tableUserGroupUser.userUuid}`) + + const sql = new SqlSelectBuilder() + .select(tableUser.uuid, tableUser.name, tableUser.email) + .from(tableUserGroupUser) + .join(joinClause) + .where(`${tableUserGroupUser.groupUuid} = $1`) + .build() + + return client.map(sql, [groupUuid], (row) => Objects.camelize(row, { limitToLevel: 1 })) +} diff --git a/src/repository/userGroup/read.ts b/src/repository/userGroup/read.ts new file mode 100644 index 00000000..887ccb7a --- /dev/null +++ b/src/repository/userGroup/read.ts @@ -0,0 +1,37 @@ +import { BaseProtocol, DB, DBs, SqlSelectBuilder, TableUserGroup } from '../../db' +import { SqlSelectCountBuilder } from '../../db/sql' +import { UserGroup } from './types' + +export const count = (params: { surveyUuid: string }, client: BaseProtocol = DB): Promise => { + const { surveyUuid } = params + const table = new TableUserGroup() + const whereValues = { [table.surveyUuid.columnName]: surveyUuid } + const sql = new SqlSelectCountBuilder().selectCountFrom(table).where(whereValues).build() + return client.one(sql, whereValues, DBs.transformCallbackCount) +} + +export const getAll = (params: { surveyUuid: string }, client: BaseProtocol = DB): Promise => { + const { surveyUuid } = params + if (!surveyUuid) throw new Error(`missingParams, ${JSON.stringify(params)}`) + + const table = new TableUserGroup() + const sql = new SqlSelectBuilder() + .select(table.uuid, table.surveyUuid, table.props, table.dateCreated, table.dateModified) + .from(table) + .where(`${table.surveyUuid} = $1`) + .build() + + return client.map(sql, [surveyUuid], (row) => DBs.transformCallback({ row })) +} + +export const getByUuid = (params: { uuid: string }, client: BaseProtocol = DB): Promise => { + const { uuid } = params + const table = new TableUserGroup() + const sql = new SqlSelectBuilder() + .select(table.uuid, table.surveyUuid, table.props, table.dateCreated, table.dateModified) + .from(table) + .where(`${table.uuid} = $1`) + .build() + + return client.oneOrNone(sql, [uuid], (row) => DBs.transformCallback({ row })) +} diff --git a/src/repository/userGroup/types.ts b/src/repository/userGroup/types.ts new file mode 100644 index 00000000..0d8d4066 --- /dev/null +++ b/src/repository/userGroup/types.ts @@ -0,0 +1,17 @@ +export interface UserGroupQualifier { + name: string + value: string +} + +export interface UserGroupProps { + name?: string + qualifiers?: UserGroupQualifier[] +} + +export interface UserGroup { + uuid: string + surveyUuid?: string + props: UserGroupProps + dateCreated?: string + dateModified?: string +} diff --git a/src/repository/userGroup/update.ts b/src/repository/userGroup/update.ts new file mode 100644 index 00000000..092544b7 --- /dev/null +++ b/src/repository/userGroup/update.ts @@ -0,0 +1,43 @@ +import { BaseProtocol, DB, DBs, TableUserGroup } from '../../db' +import { SqlUpdateBuilder } from '../../db/sql' +import { UserGroup } from './types' + +const dateModifiedNow = "(now() AT TIME ZONE 'UTC')" + +// Full props replace. +export const update = ( + params: { uuid: string; props: UserGroup['props'] }, + client: BaseProtocol = DB +): Promise => { + const { uuid, props } = params + const table = new TableUserGroup() + + const sql = new SqlUpdateBuilder() + .update(table) + .set(table.props, '$1::jsonb') + .set(table.dateModified, dateModifiedNow) + .where(`${table.uuid} = $2`) + .returning(...table.summaryColumns) + .build() + + return client.one(sql, [props, uuid], (row) => DBs.transformCallback({ row })) +} + +// Partial props merge. +export const updateProps = ( + params: { uuid: string; props: Partial }, + client: BaseProtocol = DB +): Promise => { + const { uuid, props } = params + const table = new TableUserGroup() + + const sql = new SqlUpdateBuilder() + .update(table) + .set(table.props, `${table.props} || $1::jsonb`) + .set(table.dateModified, dateModifiedNow) + .where(`${table.uuid} = $2`) + .returning(...table.summaryColumns) + .build() + + return client.one(sql, [props, uuid], (row) => DBs.transformCallback({ row })) +} diff --git a/src/server/arenaServer/registerServices.ts b/src/server/arenaServer/registerServices.ts index a2e4fa21..c1597dbf 100644 --- a/src/server/arenaServer/registerServices.ts +++ b/src/server/arenaServer/registerServices.ts @@ -9,6 +9,7 @@ import { UserTempAuthTokenServiceServer, UserServiceServer, User2FAServiceServer, + UserGroupServiceServer, } from '../../service' import { ServerServiceType } from './serverServiceType' @@ -23,3 +24,4 @@ export const registerServices = (): ServiceRegistry => .registerService(ServerServiceType.message, MessageServiceServer) .registerService(ServerServiceType.userTempAuthToken, UserTempAuthTokenServiceServer) .registerService(ServerServiceType.user2FA, User2FAServiceServer) + .registerService(ServerServiceType.userGroup, UserGroupServiceServer) diff --git a/src/server/arenaServer/serverServiceType.ts b/src/server/arenaServer/serverServiceType.ts index 0e0ed602..983f423f 100644 --- a/src/server/arenaServer/serverServiceType.ts +++ b/src/server/arenaServer/serverServiceType.ts @@ -3,4 +3,5 @@ export enum ServerServiceType { message = 'message', userTempAuthToken = 'userTempAuthToken', user2FA = 'user2FA', + userGroup = 'userGroup', } diff --git a/src/service/index.ts b/src/service/index.ts index 9e3fb116..1e729214 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -15,3 +15,5 @@ export { User2FAServiceServer } from './user2FA' export { User2FAServiceErrorMessageKeys } from './user2FA' export type { User2FAService } from './user2FA' export type { UserTempAuthTokenService } from './userTempAuthToken' +export type { UserGroupService } from './userGroup' +export { UserGroupServiceServer } from './userGroup' diff --git a/src/service/userGroup/index.ts b/src/service/userGroup/index.ts new file mode 100644 index 00000000..7d141019 --- /dev/null +++ b/src/service/userGroup/index.ts @@ -0,0 +1,45 @@ +import { ArenaService } from '@openforis/arena-core' +import { BaseProtocol } from '../../db' +import { UserGroup, UserGroupMember, UserGroupProps, UserGroupRepository } from '../../repository/userGroup' + +const { + count, + getAll, + getByUuid, + getManyByUser, + insert, + update, + updateProps, + deleteItem, + addMember, + removeMember, + getMembers, +} = UserGroupRepository + +export interface UserGroupService extends ArenaService { + count(params: { surveyUuid: string }, client?: BaseProtocol): Promise + getAll(params: { surveyUuid: string }, client?: BaseProtocol): Promise + getByUuid(params: { uuid: string }, client?: BaseProtocol): Promise + getManyByUser(params: { userUuid: string; surveyUuid?: string }, client?: BaseProtocol): Promise + insert(params: { surveyUuid: string; item: Partial }, client?: BaseProtocol): Promise + update(params: { uuid: string; props: UserGroupProps }, client?: BaseProtocol): Promise + updateProps(params: { uuid: string; props: Partial }, client?: BaseProtocol): Promise + deleteItem(params: { uuid: string }, client?: BaseProtocol): Promise + addMember(params: { groupUuid: string; userUuid: string }, client?: BaseProtocol): Promise + removeMember(params: { groupUuid: string; userUuid: string }, client?: BaseProtocol): Promise + getMembers(params: { groupUuid: string }, client?: BaseProtocol): Promise +} + +export const UserGroupServiceServer: UserGroupService = { + count, + getAll, + getByUuid, + getManyByUser, + insert, + update, + updateProps, + deleteItem, + addMember, + removeMember, + getMembers, +}