diff --git a/database/migrations/20240114212233_addAssigneeToForm.js b/database/migrations/20240114212233_addAssigneeToForm.js new file mode 100644 index 0000000..f8dbc1b --- /dev/null +++ b/database/migrations/20240114212233_addAssigneeToForm.js @@ -0,0 +1,11 @@ +exports.up = async function (knex) { + await knex.schema.alterTable('forms', (table) => { + table.integer('assigneeId').unsigned(); + }); +}; + +exports.down = async function (knex) { + await knex.schema.alterTable('forms', (table) => { + table.dropColumn('assigneeId'); + }); +}; diff --git a/mixins/database.mixin.ts b/mixins/database.mixin.ts index ddd50ea..12d3b20 100644 --- a/mixins/database.mixin.ts +++ b/mixins/database.mixin.ts @@ -1,11 +1,11 @@ 'use strict'; import _ from 'lodash'; -const DbService = require('@moleculer/database').Service; -import { config } from '../knexfile'; -import filtersMixin from 'moleculer-knex-filters'; import { Context } from 'moleculer'; +import filtersMixin from 'moleculer-knex-filters'; +import { config } from '../knexfile'; import { parseToJsonIfNeeded } from '../utils'; +const DbService = require('@moleculer/database').Service; export default function (opts: any = {}) { const adapter: any = { diff --git a/services/api.service.ts b/services/api.service.ts index 2fab3d0..16f3796 100644 --- a/services/api.service.ts +++ b/services/api.service.ts @@ -1,12 +1,16 @@ +import { Handlers } from '@sentry/node'; import pick from 'lodash/pick'; import moleculer, { Context, Errors } from 'moleculer'; import { Action, Method, Service } from 'moleculer-decorators'; import ApiGateway from 'moleculer-web'; -import { COMMON_DELETED_SCOPES, EndpointType, RequestMessage } from '../types'; +import { + COMMON_DELETED_SCOPES, + EndpointType, + RequestMessage, + throwUnauthorizedError, +} from '../types'; import { Tenant } from './tenants.service'; -import { User } from './users.service'; -import { throwUnauthorizedError } from '../types'; -import { Handlers } from '@sentry/node'; +import { User, UserType } from './users.service'; export interface UserAuthMeta { user: User; profile?: Tenant; @@ -265,7 +269,19 @@ export default class ApiService extends moleculer.Service { const app: any = await ctx.call('auth.apps.resolveToken'); if (user && user.id) { - ctx.meta.authUser = authUser; + const authUserWithGroups: any = await ctx.call('auth.users.get', { + id: authUser.id, + populate: 'groups', + }); + + const adminOfGroups = authUserWithGroups.groups + .filter( + (group: any) => + group.apps.includes(app.id) && group.role === UserType.ADMIN + ) + .map((group: any) => group.id); + + ctx.meta.authUser = { ...authUser, adminOfGroups }; ctx.meta.authToken = token; ctx.meta.app = app; diff --git a/services/auth.service.ts b/services/auth.service.ts index 3645c6c..3fcf25e 100644 --- a/services/auth.service.ts +++ b/services/auth.service.ts @@ -4,14 +4,14 @@ import moleculer, { Context } from 'moleculer'; import { Action, Event, Method, Service } from 'moleculer-decorators'; import authMixin from 'biip-auth-nodejs/mixin'; -import { UserAuthMeta } from './api.service'; -import { User, UserType } from './users.service'; import { AUTH_FREELANCERS_GROUP_ID, EndpointType, throwNotFoundError, } from '../types'; +import { UserAuthMeta } from './api.service'; import { TenantUserRole } from './tenantUsers.service'; +import { User, UserType } from './users.service'; @Service({ name: 'auth', @@ -67,12 +67,6 @@ export default class AuthService extends moleculer.Service { }; } - if (user.type === UserType.ADMIN) { - data.tasks = await ctx.call('users.getTasksCounts', { - userId: user.id, - }); - } - return data; } diff --git a/services/forms.service.ts b/services/forms.service.ts index 57858b9..1111c34 100644 --- a/services/forms.service.ts +++ b/services/forms.service.ts @@ -4,6 +4,7 @@ import moleculer, { Context, RestSchema } from 'moleculer'; import { Action, Event, Method, Service } from 'moleculer-decorators'; import { FeatureCollection } from 'geojsonjs'; +import { isEmpty } from 'lodash'; import PostgisMixin from 'moleculer-postgis'; import DbConnection from '../mixins/database.mixin'; import { @@ -13,17 +14,31 @@ import { COMMON_FIELDS, COMMON_SCOPES, ContextMeta, + EndpointType, EntityChangedParams, FieldHookCallback, NOTIFY_ADMIN_EMAIL, TENANT_FIELD, + throwBadRequestError, + USER_PUBLIC_GET, + USER_PUBLIC_POPULATE, } from '../types'; -import { emailCanBeSent, notifyOnFormUpdate } from '../utils/mails'; + +import { + emailCanBeSent, + notifyFormAssignee, + notifyOnFormUpdate, +} from '../utils/mails'; import { UserAuthMeta } from './api.service'; import { FormHistoryTypes } from './forms.histories.service'; import { UETKObject, UETKObjectType } from './objects.service'; import { Tenant } from './tenants.service'; -import { USERS_DEFAULT_SCOPES, User, UserType } from './users.service'; +import { + User, + USERS_DEFAULT_SCOPES, + USERS_WITHOUT_NOT_ADMINS_SCOPE, + UserType, +} from './users.service'; type FormStatusChanged = { statusChanged: boolean }; @@ -34,6 +49,7 @@ export interface Form extends BaseModelInterface { cadastralId: string | number; type: string; objectType: string; + assignee: number; } export const FormStatus = { @@ -63,12 +79,24 @@ const VISIBLE_TO_USER_SCOPE = 'visibleToUser'; const AUTH_PROTECTED_SCOPES = [...COMMON_DEFAULT_SCOPES, VISIBLE_TO_USER_SCOPE]; const populatePermissions = (field: string) => { - return function (ctx: Context<{}, UserAuthMeta>, _values: any, forms: any[]) { - const { user, profile } = ctx?.meta; - return forms.map((form: any) => { - const editingPermissions = this.hasPermissionToEdit(form, user, profile); - return !!editingPermissions[field]; - }); + return async function ( + ctx: Context<{}, UserAuthMeta>, + _values: any, + forms: any[] + ) { + const { user, profile, authUser } = ctx?.meta; + return await Promise.all( + forms.map(async (form: any) => { + const editingPermissions = await this.hasPermissionToEdit( + ctx, + form, + user, + authUser, + profile + ); + return !!editingPermissions[field]; + }) + ); }; }; @@ -181,12 +209,62 @@ const populatePermissions = (field: string) => { }, }, + assignee: { + type: 'number', + columnType: 'integer', + populate: USER_PUBLIC_POPULATE, + get: USER_PUBLIC_GET, + set: async ({ + ctx, + value, + entity, + }: FieldHookCallback & ContextMeta) => { + const { user } = ctx?.meta; + + if (!entity?.id || !user?.id || !value) return null; + + if (entity.assigneeId === value) return value; + + const error = 'Assignee cannot be set.'; + + if (user.type === UserType.USER) { + throwBadRequestError(error); + } + + const availableAssigneeList: { rows: User[] } = await ctx.call( + 'forms.getAssignees' + ); + const assigneeAuthUser = availableAssigneeList.rows.find( + (assignee) => assignee.id === Number(value) + ); + + if (!assigneeAuthUser) { + throwBadRequestError(error); + } + + const localUser: User = await ctx.call('users.findOrCreate', { + authUser: assigneeAuthUser, + update: true, + }); + + return localUser.id; + }, + validate: 'validateAssignee', + columnName: 'assigneeId', + }, + canEdit: { type: 'boolean', virtual: true, populate: populatePermissions('edit'), }, + canAssign: { + type: 'boolean', + virtual: true, + populate: populatePermissions('assign'), + }, + canValidate: { type: 'boolean', virtual: true, @@ -269,6 +347,90 @@ export default class FormsService extends moleculer.Service { }); } + @Method + async validateAssignee({ ctx, value, entity }: FieldHookCallback) { + const { user, authUser, profile } = ctx?.meta; + + if (!entity?.id || !user?.id || entity.assigneeId === value) return true; + + const editingPermissions = await this.hasPermissionToEdit( + ctx, + entity, + user, + authUser, + profile + ); + + if (!editingPermissions.assign) { + return 'Assignee cannot be set.'; + } + + return true; + } + + @Action({ + rest: 'GET /assignees', + auth: EndpointType.ADMIN, + }) + async getAssignees(ctx: Context<{}, UserAuthMeta>) { + const { authUser } = ctx.meta; + + if (authUser?.type === UserType.ADMIN && isEmpty(authUser?.adminOfGroups)) + return { + rows: [ + { + id: authUser.id, + firstName: authUser.firstName, + lastName: authUser.lastName, + phone: authUser.phone, + email: authUser.email, + type: authUser?.type, + }, + ], + total: 1, + page: 1, + pageSize: 10, + totalPages: 1, + }; + + return await ctx.call('auth.users.list', { + ...ctx.params, + query: { + type: UserType.ADMIN, + group: { + $in: authUser?.adminOfGroups, + }, + }, + fields: ['id', 'firstName', 'lastName', 'phone', 'email', 'type'], + }); + } + + @Action({ + rest: 'PATCH /:id/assignee/:assignee?', + params: { + id: { + type: 'number', + convert: true, + }, + assignee: { + optional: true, + type: 'number', + convert: true, + }, + }, + types: [EndpointType.ADMIN], + }) + async setAssignee( + ctx: Context<{ id: number; assignee: number }, UserAuthMeta> + ) { + await this.updateEntity(ctx, { + id: ctx.params.id, + assignee: ctx.params.assignee || null, + }); + + return { success: true }; + } + @Action({ rest: { method: 'POST', @@ -292,8 +454,8 @@ export default class FormsService extends moleculer.Service { } @Method - validateStatus({ ctx, value, entity }: FieldHookCallback) { - const { user, profile } = ctx.meta; + async validateStatus({ ctx, value, entity }: FieldHookCallback) { + const { user, profile, authUser } = ctx.meta; if (!value || !user?.id) return true; const adminStatuses = [ @@ -309,7 +471,13 @@ export default class FormsService extends moleculer.Service { return newStatuses.includes(value) || error; } - const editingPermissions = this.hasPermissionToEdit(entity, user, profile); + const editingPermissions = await this.hasPermissionToEdit( + ctx, + entity, + user, + authUser, + profile + ); if (editingPermissions.edit) { return value === FormStatus.SUBMITTED || error; @@ -321,15 +489,18 @@ export default class FormsService extends moleculer.Service { } @Method - hasPermissionToEdit( + async hasPermissionToEdit( + ctx: any, form: any, user?: User, + authUser?: any, profile?: Tenant - ): { + ): Promise<{ edit: boolean; validate: boolean; - } { - const invalid = { edit: false, validate: false }; + assign: boolean; + }> { + const invalid = { edit: false, validate: false, assign: false }; const tenant = form.tenant || form.tenantId; @@ -344,27 +515,54 @@ export default class FormsService extends moleculer.Service { return { edit: true, validate: true, + assign: true, }; } const isCreatedByUser = !tenant && user && user.id === form.createdBy; const isCreatedByTenant = profile && profile.id === tenant; - if (isCreatedByTenant || isCreatedByUser) { - return { - validate: false, - edit: [FormStatus.RETURNED].includes(form.status), - }; - } else if (user.type === UserType.ADMIN) { - return { - edit: false, - validate: [FormStatus.CREATED, FormStatus.SUBMITTED].includes( - form.status - ), - }; + const canEdit = + [FormStatus.RETURNED].includes(form.status) && + (isCreatedByTenant || isCreatedByUser); + + const isSuperAdminOrAssignee = + authUser?.type === UserType.SUPER_ADMIN || form.assigneeId === user.id; + + const canValidate = + isSuperAdminOrAssignee && + [FormStatus.CREATED, FormStatus.SUBMITTED].includes(form.status); + + let canAssign = true; + + if (isCreatedByUser || user?.type === UserType.USER) { + canAssign = false; } - return invalid; + if (canAssign && !!form?.assigneeId) { + const assignee: User = await ctx.call('users.resolve', { + id: form.assigneeId, + scope: USERS_WITHOUT_NOT_ADMINS_SCOPE, + }); + + const availableAssigneeList: { rows: User[] } = await ctx.call( + 'forms.getAssignees' + ); + + const assigneeAuthUser = availableAssigneeList.rows.find( + (availableAssignee) => availableAssignee.id === assignee?.authUser + ); + + if (!assigneeAuthUser && authUser.type !== UserType.SUPER_ADMIN) { + canAssign = false; + } + } + + return { + edit: canEdit, + validate: canValidate, + assign: canAssign, + }; } @Method @@ -406,6 +604,8 @@ export default class FormsService extends moleculer.Service { async sendNotificationOnStatusChange(form: Form) { let object: Partial; + if (!emailCanBeSent() || !object?.name) return; + if (form.cadastralId) { object = await this.broker.call('objects.findOne', { query: { cadastralId: form.cadastralId }, @@ -429,22 +629,47 @@ export default class FormsService extends moleculer.Service { ); } - const user: User = await this.broker.call('users.resolve', { - id: form.createdBy, - scope: USERS_DEFAULT_SCOPES, - }); + if ([FormStatus.SUBMITTED].includes(form.status)) { + const assignee: User = await this.broker.call('users.resolve', { + id: form.assignee, + scope: USERS_WITHOUT_NOT_ADMINS_SCOPE, + }); - if (!user?.email) return; + if (!assignee?.email) return; - notifyOnFormUpdate( - user.email, - form.status, - form.id, - form.type, - object.name, - object.id, - user.type === UserType.ADMIN - ); + return notifyOnFormUpdate( + assignee.email, + form.status, + form.id, + form.type, + object.name, + object.id, + true + ); + } + + if ( + [FormStatus.RETURNED, FormStatus.REJECTED, FormStatus.APPROVED].includes( + form.status + ) + ) { + const user: User = await this.broker.call('users.resolve', { + id: form.createdBy, + scope: USERS_DEFAULT_SCOPES, + }); + + if (!user?.email) return; + + return notifyOnFormUpdate( + user.email, + form.status, + form.id, + form.type, + object.name, + object.id, + user.type === UserType.ADMIN + ); + } } @Event() @@ -468,6 +693,28 @@ export default class FormsService extends moleculer.Service { await this.sendNotificationOnStatusChange(form); } + + if (!!form?.assignee && prevForm?.assignee !== form?.assignee) { + const assignee: User = await ctx.call('users.resolve', { + id: form.assignee, + scope: USERS_WITHOUT_NOT_ADMINS_SCOPE, + }); + + if (!emailCanBeSent() || !assignee?.email) return; + + const object = await this.getObjectFromCadastralId( + form.cadastralId, + form.objectName + ); + + notifyFormAssignee( + assignee.email, + form.id, + form.type, + form.objectName, + object.id + ); + } } @Event() diff --git a/services/users.service.ts b/services/users.service.ts index 386d0b3..8e39543 100644 --- a/services/users.service.ts +++ b/services/users.service.ts @@ -1,26 +1,27 @@ 'use strict'; import moleculer, { Context } from 'moleculer'; -import { Action, Event, Method, Service } from 'moleculer-decorators'; +import { Action, Event, Service } from 'moleculer-decorators'; -import { UserAuthMeta } from './api.service'; import DbConnection from '../mixins/database.mixin'; import { - COMMON_FIELDS, + BaseModelInterface, COMMON_DEFAULT_SCOPES, + COMMON_FIELDS, COMMON_SCOPES, - FieldHookCallback, - BaseModelInterface, EndpointType, - throwUnauthorizedError, + FieldHookCallback, throwNotFoundError, + throwUnauthorizedError, } from '../types'; -import { TenantUserRole } from './tenantUsers.service'; +import { UserAuthMeta } from './api.service'; import { Tenant } from './tenants.service'; +import { TenantUserRole } from './tenantUsers.service'; export enum UserType { ADMIN = 'ADMIN', USER = 'USER', + SUPER_ADMIN = 'SUPER_ADMIN', } export interface User extends BaseModelInterface { firstName: string; @@ -41,7 +42,7 @@ const AUTH_PROTECTED_SCOPES = [ ]; export const USERS_WITHOUT_AUTH_SCOPES = [`-${VISIBLE_TO_USER_SCOPE}`]; -const USERS_WITHOUT_NOT_ADMINS_SCOPE = [`-${NOT_ADMINS_SCOPE}`]; +export const USERS_WITHOUT_NOT_ADMINS_SCOPE = [`-${NOT_ADMINS_SCOPE}`]; export const USERS_DEFAULT_SCOPES = [ ...USERS_WITHOUT_AUTH_SCOPES, ...USERS_WITHOUT_NOT_ADMINS_SCOPE, @@ -352,7 +353,7 @@ export default class UsersService extends moleculer.Service { const scope = [...USERS_WITHOUT_AUTH_SCOPES]; - const authUserIsAdmin = ['SUPER_ADMIN', UserType.ADMIN].includes( + const authUserIsAdmin = [UserType.SUPER_ADMIN, UserType.ADMIN].includes( authUser.type ); @@ -378,11 +379,13 @@ export default class UsersService extends moleculer.Service { }; if (user?.id) { - return ctx.call('users.update', { - id: user.id, - ...dataToSave, - scope, - }); + return this.updateEntity( + ctx, + { id: user.id, ...dataToSave }, + { + scope, + } + ); } // let user to customize his phone and email diff --git a/types/constants.ts b/types/constants.ts index 6028ec7..98a81c7 100644 --- a/types/constants.ts +++ b/types/constants.ts @@ -1,6 +1,7 @@ import _ from 'lodash'; import Moleculer, { Context, Errors } from 'moleculer'; import { UserAuthMeta } from '../services/api.service'; +import { FormType } from '../services/forms.service'; import { FieldHookCallback } from './'; export const NOTIFY_ADMIN_EMAIL = 'viktorija.ramune@gamta.lt'; diff --git a/utils/mails.ts b/utils/mails.ts index 849e0ed..76a2d97 100644 --- a/utils/mails.ts +++ b/utils/mails.ts @@ -19,6 +19,35 @@ function hostUrl(isAdmin: boolean = false) { return isAdmin ? process.env.ADMIN_HOST : process.env.APP_HOST; } +export function notifyFormAssignee( + email: string, + formId: number | string, + formType: string, + objectName: string, + objectId: string +) { + if (objectId) { + objectName = `${objectName}, ${objectId}`; + } + + const formTypeTranslates: any = { + [FormType.NEW]: 'įregistravimo', + [FormType.EDIT]: 'redagavimo', + [FormType.REMOVE]: 'išregistravimo', + }; + + return client.sendEmailWithTemplate({ + From: sender, + To: email.toLowerCase(), + TemplateId: 34487978, + TemplateModel: { + actionUrl: `${hostUrl(true)}/uetk/teikimo-anketos/${formId}`, + typeText: formTypeTranslates[formType] || 'teikimo', + objectName, + }, + }); +} + export function notifyOnFormUpdate( email: string, status: string, @@ -36,12 +65,6 @@ export function notifyOnFormUpdate( [FormStatus.RETURNED]: 'Grąžintas taisymui', }; - const formTypeTranlates: any = { - [FormType.NEW]: 'įregistravimo', - [FormType.EDIT]: 'redagavimo', - [FormType.REMOVE]: 'išregistravimo', - }; - const updateType = updateTypes[status] || ''; if (!updateType) return; @@ -52,6 +75,12 @@ export function notifyOnFormUpdate( objectName = `${objectName}, ${objectId}`; } + const formTypeTranslates: any = { + [FormType.NEW]: 'įregistravimo', + [FormType.EDIT]: 'redagavimo', + [FormType.REMOVE]: 'išregistravimo', + }; + return client.sendEmailWithTemplate({ From: sender, To: email.toLowerCase(), @@ -59,7 +88,7 @@ export function notifyOnFormUpdate( TemplateModel: { title: updateType, titleText: updateType.toLowerCase(), - typeText: formTypeTranlates[formType] || 'teikimo', + typeText: formTypeTranslates[formType] || 'teikimo', objectName, actionUrl: `${hostUrl(isAdmin)}/${path}/${formId}`, },