From 6ac7689bae7c9a7aae3679a42c65e2716386db44 Mon Sep 17 00:00:00 2001 From: LWangllix Date: Thu, 18 Jan 2024 00:10:27 +0200 Subject: [PATCH 1/8] init --- .../20240114212233_addAssigneeToForm.js | 11 + services/api.service.ts | 26 +- services/auth.service.ts | 10 +- services/forms.service.ts | 273 +++++++++++++++--- services/users.service.ts | 43 +-- utils/mails.ts | 14 +- 6 files changed, 309 insertions(+), 68 deletions(-) create mode 100644 database/migrations/20240114212233_addAssigneeToForm.js 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/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 155e86e..8258c9a 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,18 +14,26 @@ import { COMMON_FIELDS, COMMON_SCOPES, ContextMeta, + EndpointType, EntityChangedParams, FieldHookCallback, NOTIFY_ADMIN_EMAIL, TENANT_FIELD, + throwBadRequestError, + USER_PUBLIC_GET, + USER_PUBLIC_POPULATE, } from '../types'; import { getObjectByCadastralId } from '../utils'; -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 { UETKObjectType } from './objects.service'; import { Tenant } from './tenants.service'; -import { USERS_DEFAULT_SCOPES, User, UserType } from './users.service'; +import { User, USERS_DEFAULT_SCOPES, UserType } from './users.service'; type FormStatusChanged = { statusChanged: boolean }; @@ -35,6 +44,7 @@ export interface Form extends BaseModelInterface { cadastralId: string | number; type: string; objectType: string; + assignee: number; } export const FormStatus = { @@ -65,9 +75,14 @@ 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; + const { user, profile, authUser } = ctx?.meta; return forms.map((form: any) => { - const editingPermissions = this.hasPermissionToEdit(form, user, profile); + const editingPermissions = this.hasPermissionToEdit( + form, + user, + authUser, + profile + ); return !!editingPermissions[field]; }); }; @@ -182,6 +197,84 @@ 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, authUser } = ctx?.meta; + + if (!entity?.id || !user?.id || !value) return null; + + const assigneeList: { rows: User[] } = await ctx.call( + 'forms.getAssignees' + ); + + if ( + user.type === UserType.USER || + !assigneeList.rows.find((assignee) => assignee.id === Number(value)) + ) { + throwBadRequestError('Assignee cannot be set.'); + } + + const createdBy: User = await ctx.call('users.resolve', { + id: entity.createdBy, + }); + + let prevAssignee = null; + if (!!entity.assigneeId) { + prevAssignee = (await ctx.call('users.resolve', { + id: entity.assigneeId, + })) as User; + } + + const newAssignee = Number(value); + const prevAssigneeId = Number(prevAssignee?.authUser); + const userIsCreator = Number(createdBy.authUser) === newAssignee; + + if ( + authUser.type === UserType.ADMIN && + !authUser.adminOfGroups.length + ) { + const assignedToHimself = + Number(authUser.id) === Number(prevAssigneeId); + + if (!!newAssignee && !!prevAssigneeId) { + throwBadRequestError('Assignee already exists.'); + } else if (!newAssignee && !assignedToHimself) { + throwBadRequestError('Cannot unassign others.'); + } + } + + if (!newAssignee && !prevAssigneeId) { + throwBadRequestError('Already unassigned.'); + } else if (!!newAssignee && userIsCreator) { + throwBadRequestError('Cannot assign to creator.'); + } + const assigneeAuthUser: any = await ctx.call('auth.users.get', { + id: value, + }); + + const localUser: User = await ctx.call('users.findOrCreate', { + authUser: assigneeAuthUser, + update: true, + hideAdmins: false, + }); + + if (emailCanBeSent() && localUser.email) { + notifyFormAssignee(localUser.email, entity.id); + } + + return localUser.id; + }, + columnName: 'assigneeId', + }, + object: { type: 'object', virtual: true, @@ -204,6 +297,12 @@ const populatePermissions = (field: string) => { populate: populatePermissions('edit'), }, + canAssign: { + type: 'boolean', + virtual: true, + populate: populatePermissions('assign'), + }, + canValidate: { type: 'boolean', virtual: true, @@ -286,6 +385,67 @@ export default class FormsService extends moleculer.Service { }); } + @Action({ + rest: 'GET /assignees', + auth: EndpointType.ADMIN, + }) + async getAssignees(ctx: Context<{}, UserAuthMeta>) { + const { authUser, user } = ctx.meta; + + if (authUser?.type === UserType.ADMIN && isEmpty(authUser?.adminOfGroups)) + return { + rows: [ + { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + phone: user.phone, + email: user.email, + }, + ], + total: 1, + page: 1, + }; + + return await ctx.call('auth.users.list', { + ...ctx.params, + query: { + type: UserType.ADMIN, + group: { + $in: 1455, + }, + }, + fields: ['id', 'firstName', 'lastName', 'phone', 'email'], + pageSize: 99999, + }); + } + + @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', @@ -310,7 +470,7 @@ export default class FormsService extends moleculer.Service { @Method validateStatus({ ctx, value, entity }: FieldHookCallback) { - const { user, profile } = ctx.meta; + const { user, profile, authUser } = ctx.meta; if (!value || !user?.id) return true; const adminStatuses = [ @@ -326,7 +486,12 @@ export default class FormsService extends moleculer.Service { return newStatuses.includes(value) || error; } - const editingPermissions = this.hasPermissionToEdit(entity, user, profile); + const editingPermissions = this.hasPermissionToEdit( + entity, + user, + authUser, + profile + ); if (editingPermissions.edit) { return value === FormStatus.SUBMITTED || error; @@ -341,12 +506,14 @@ export default class FormsService extends moleculer.Service { hasPermissionToEdit( form: any, user?: User, + authUser?: any, profile?: Tenant ): { edit: boolean; validate: boolean; + assign: boolean; } { - const invalid = { edit: false, validate: false }; + const invalid = { edit: false, validate: false, assign: false }; const tenant = form.tenant || form.tenantId; @@ -361,27 +528,36 @@ 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 - ), - }; - } - - return invalid; + 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); + + const canAssign = + !isCreatedByUser && + authUser.type !== UserType.USER && + (!isEmpty(authUser.adminOfGroups) || + !form?.assigneeId || + isSuperAdminOrAssignee); + + return { + edit: canEdit, + validate: canValidate, + assign: canAssign, + }; } @Method @@ -454,22 +630,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_DEFAULT_SCOPES, + }); - 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() diff --git a/services/users.service.ts b/services/users.service.ts index 386d0b3..b6a8abf 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; @@ -319,6 +320,10 @@ export default class UsersService extends moleculer.Service { type: 'boolean', default: false, }, + hideAdmins: { + type: 'boolean', + default: true, + }, firstName: { type: 'string', optional: true, @@ -345,18 +350,20 @@ export default class UsersService extends moleculer.Service { email?: string; phone?: string; update?: boolean; + hideAdmins?: boolean; }> ) { - const { authUser, update, firstName, lastName, email, phone } = ctx.params; + const { authUser, update, firstName, lastName, email, phone, hideAdmins } = + ctx.params; if (!authUser || !authUser.id) return; const scope = [...USERS_WITHOUT_AUTH_SCOPES]; - const authUserIsAdmin = ['SUPER_ADMIN', UserType.ADMIN].includes( + const authUserIsAdmin = [UserType.SUPER_ADMIN, UserType.ADMIN].includes( authUser.type ); - if (authUserIsAdmin) { + if (hideAdmins && authUserIsAdmin) { scope.push(...USERS_WITHOUT_NOT_ADMINS_SCOPE); } @@ -377,14 +384,6 @@ export default class UsersService extends moleculer.Service { phone: phone || authUser.phone, }; - if (user?.id) { - return ctx.call('users.update', { - id: user.id, - ...dataToSave, - scope, - }); - } - // let user to customize his phone and email if (user?.email) { delete dataToSave.email; @@ -393,6 +392,14 @@ export default class UsersService extends moleculer.Service { delete dataToSave.phone; } + if (user?.id) { + return ctx.call('users.update', { + id: user.id, + ...dataToSave, + scope, + }); + } + return ctx.call('users.create', { authUser: authUser.id, ...dataToSave, diff --git a/utils/mails.ts b/utils/mails.ts index 849e0ed..18bacea 100644 --- a/utils/mails.ts +++ b/utils/mails.ts @@ -12,13 +12,25 @@ const sender = 'noreply@biip.lt'; export function emailCanBeSent() { if (!client) return false; - return ['production'].includes(process.env.NODE_ENV); + return true; + //return ['production'].includes(process.env.NODE_ENV); } function hostUrl(isAdmin: boolean = false) { return isAdmin ? process.env.ADMIN_HOST : process.env.APP_HOST; } +export function notifyFormAssignee(email: string, formId: number | string) { + return client.sendEmailWithTemplate({ + From: sender, + To: email.toLowerCase(), + TemplateId: 34487978, + TemplateModel: { + actionUrl: `${hostUrl()}/uetk/teikimo-anketos/${formId}`, + }, + }); +} + export function notifyOnFormUpdate( email: string, status: string, From d99c6f97f244f3c3349df0f1eee4e8a50733283f Mon Sep 17 00:00:00 2001 From: LWangllix Date: Thu, 18 Jan 2024 11:39:31 +0200 Subject: [PATCH 2/8] naming fixes --- services/forms.service.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/forms.service.ts b/services/forms.service.ts index 8258c9a..aed24af 100644 --- a/services/forms.service.ts +++ b/services/forms.service.ts @@ -211,13 +211,15 @@ const populatePermissions = (field: string) => { if (!entity?.id || !user?.id || !value) return null; - const assigneeList: { rows: User[] } = await ctx.call( + const availableAssigneeList: { rows: User[] } = await ctx.call( 'forms.getAssignees' ); if ( user.type === UserType.USER || - !assigneeList.rows.find((assignee) => assignee.id === Number(value)) + !availableAssigneeList.rows.find( + (assignee) => assignee.id === Number(value) + ) ) { throwBadRequestError('Assignee cannot be set.'); } @@ -412,7 +414,7 @@ export default class FormsService extends moleculer.Service { query: { type: UserType.ADMIN, group: { - $in: 1455, + $in: authUser?.adminOfGroups, }, }, fields: ['id', 'firstName', 'lastName', 'phone', 'email'], From 7e7ae851f8eb5ef712e866e16f73fae6a0d6ba1d Mon Sep 17 00:00:00 2001 From: LWangllix Date: Thu, 18 Jan 2024 16:52:54 +0200 Subject: [PATCH 3/8] test emails --- services/forms.service.ts | 143 ++++++++++++++++++++------------------ services/users.service.ts | 32 ++++----- types/constants.ts | 1 + utils/mails.ts | 34 ++++++--- 4 files changed, 119 insertions(+), 91 deletions(-) diff --git a/services/forms.service.ts b/services/forms.service.ts index aed24af..696ce8c 100644 --- a/services/forms.service.ts +++ b/services/forms.service.ts @@ -33,7 +33,13 @@ import { UserAuthMeta } from './api.service'; import { FormHistoryTypes } from './forms.histories.service'; import { UETKObjectType } from './objects.service'; import { Tenant } from './tenants.service'; -import { User, USERS_DEFAULT_SCOPES, UserType } from './users.service'; +import { + User, + USERS_DEFAULT_SCOPES, + USERS_WITHOUT_AUTH_SCOPES, + USERS_WITHOUT_NOT_ADMINS_SCOPE, + UserType, +} from './users.service'; type FormStatusChanged = { statusChanged: boolean }; @@ -207,73 +213,32 @@ const populatePermissions = (field: string) => { value, entity, }: FieldHookCallback & ContextMeta) => { - const { user, authUser } = ctx?.meta; + const { user } = ctx?.meta; if (!entity?.id || !user?.id || !value) return null; + if (entity.assigneeId === value) return value; + const availableAssigneeList: { rows: User[] } = await ctx.call( 'forms.getAssignees' ); + const assigneeAuthUser = availableAssigneeList.rows.find( + (assignee) => assignee.id === Number(value) + ); - if ( - user.type === UserType.USER || - !availableAssigneeList.rows.find( - (assignee) => assignee.id === Number(value) - ) - ) { + if (user.type === UserType.USER || !assigneeAuthUser) { throwBadRequestError('Assignee cannot be set.'); } - const createdBy: User = await ctx.call('users.resolve', { - id: entity.createdBy, - }); - - let prevAssignee = null; - if (!!entity.assigneeId) { - prevAssignee = (await ctx.call('users.resolve', { - id: entity.assigneeId, - })) as User; - } - - const newAssignee = Number(value); - const prevAssigneeId = Number(prevAssignee?.authUser); - const userIsCreator = Number(createdBy.authUser) === newAssignee; - - if ( - authUser.type === UserType.ADMIN && - !authUser.adminOfGroups.length - ) { - const assignedToHimself = - Number(authUser.id) === Number(prevAssigneeId); - - if (!!newAssignee && !!prevAssigneeId) { - throwBadRequestError('Assignee already exists.'); - } else if (!newAssignee && !assignedToHimself) { - throwBadRequestError('Cannot unassign others.'); - } - } - - if (!newAssignee && !prevAssigneeId) { - throwBadRequestError('Already unassigned.'); - } else if (!!newAssignee && userIsCreator) { - throwBadRequestError('Cannot assign to creator.'); - } - const assigneeAuthUser: any = await ctx.call('auth.users.get', { - id: value, - }); - const localUser: User = await ctx.call('users.findOrCreate', { authUser: assigneeAuthUser, update: true, hideAdmins: false, }); - if (emailCanBeSent() && localUser.email) { - notifyFormAssignee(localUser.email, entity.id); - } - return localUser.id; }, + validate: 'validateAssignee', columnName: 'assigneeId', }, @@ -387,22 +352,52 @@ export default class FormsService extends moleculer.Service { }); } + @Method + async validateAssignee({ ctx, value, entity }: FieldHookCallback) { + const { user, authUser } = ctx?.meta; + + if (!entity?.id || !user?.id || entity.assigneeId === value) return true; + + const newAssignee = Number(value); + const prevAssigneeId = Number(entity.assigneeId); + const userIsCreator = Number(entity.createdBy) === newAssignee; + + if (authUser.type === UserType.ADMIN && !authUser.adminOfGroups.length) { + const assignedToHimself = Number(user.id) === Number(prevAssigneeId); + + if (!!newAssignee && !!prevAssigneeId) { + return 'Assignee already exists.'; + } else if (!newAssignee && !assignedToHimself) { + return 'Cannot unassign others.'; + } + } + + if (!newAssignee && !prevAssigneeId) { + return 'Already unassigned.'; + } else if (!!newAssignee && userIsCreator) { + return 'Cannot assign to creator.'; + } + + return true; + } + @Action({ rest: 'GET /assignees', auth: EndpointType.ADMIN, }) async getAssignees(ctx: Context<{}, UserAuthMeta>) { - const { authUser, user } = ctx.meta; + const { authUser } = ctx.meta; if (authUser?.type === UserType.ADMIN && isEmpty(authUser?.adminOfGroups)) return { rows: [ { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - phone: user.phone, - email: user.email, + id: authUser.id, + firstName: authUser.firstName, + lastName: authUser.lastName, + phone: authUser.phone, + email: authUser.email, + type: authUser?.type, }, ], total: 1, @@ -417,7 +412,7 @@ export default class FormsService extends moleculer.Service { $in: authUser?.adminOfGroups, }, }, - fields: ['id', 'firstName', 'lastName', 'phone', 'email'], + fields: ['id', 'firstName', 'lastName', 'phone', 'email', 'type'], pageSize: 99999, }); } @@ -558,7 +553,7 @@ export default class FormsService extends moleculer.Service { return { edit: canEdit, validate: canValidate, - assign: canAssign, + assign: true, }; } @@ -612,14 +607,8 @@ export default class FormsService extends moleculer.Service { form.objectName ); - if ( - !emailCanBeSent() || - !object?.name || - [FormStatus.SUBMITTED].includes(form.status) - ) - return; + if (!emailCanBeSent() || !object?.name) return; - // TODO: send email for admins / assignees. if ([FormStatus.CREATED].includes(form.status)) { return notifyOnFormUpdate( NOTIFY_ADMIN_EMAIL, @@ -635,7 +624,7 @@ export default class FormsService extends moleculer.Service { if ([FormStatus.SUBMITTED].includes(form.status)) { const assignee: User = await this.broker.call('users.resolve', { id: form.assignee, - scope: USERS_DEFAULT_SCOPES, + scope: USERS_WITHOUT_NOT_ADMINS_SCOPE, }); if (!assignee?.email) return; @@ -679,6 +668,28 @@ export default class FormsService extends moleculer.Service { async 'forms.updated'(ctx: Context>) { const { oldData: prevForm, data: form } = ctx.params; + 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 + ); + } + if (prevForm?.status !== form.status) { const { comment } = ctx.options?.parentCtx?.params as any; const typesByStatus = { diff --git a/services/users.service.ts b/services/users.service.ts index b6a8abf..c54f7f4 100644 --- a/services/users.service.ts +++ b/services/users.service.ts @@ -42,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, @@ -320,10 +320,7 @@ export default class UsersService extends moleculer.Service { type: 'boolean', default: false, }, - hideAdmins: { - type: 'boolean', - default: true, - }, + firstName: { type: 'string', optional: true, @@ -350,11 +347,9 @@ export default class UsersService extends moleculer.Service { email?: string; phone?: string; update?: boolean; - hideAdmins?: boolean; }> ) { - const { authUser, update, firstName, lastName, email, phone, hideAdmins } = - ctx.params; + const { authUser, update, firstName, lastName, email, phone } = ctx.params; if (!authUser || !authUser.id) return; const scope = [...USERS_WITHOUT_AUTH_SCOPES]; @@ -363,7 +358,7 @@ export default class UsersService extends moleculer.Service { authUser.type ); - if (hideAdmins && authUserIsAdmin) { + if (authUserIsAdmin) { scope.push(...USERS_WITHOUT_NOT_ADMINS_SCOPE); } @@ -384,6 +379,17 @@ export default class UsersService extends moleculer.Service { phone: phone || authUser.phone, }; + if (user?.id) { + return this.updateEntity( + ctx, + { + id: user.id, + ...dataToSave, + }, + { scope } + ); + } + // let user to customize his phone and email if (user?.email) { delete dataToSave.email; @@ -392,14 +398,6 @@ export default class UsersService extends moleculer.Service { delete dataToSave.phone; } - if (user?.id) { - return ctx.call('users.update', { - id: user.id, - ...dataToSave, - scope, - }); - } - return ctx.call('users.create', { authUser: authUser.id, ...dataToSave, 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 18bacea..ef97a14 100644 --- a/utils/mails.ts +++ b/utils/mails.ts @@ -20,13 +20,31 @@ function hostUrl(isAdmin: boolean = false) { return isAdmin ? process.env.ADMIN_HOST : process.env.APP_HOST; } -export function notifyFormAssignee(email: string, formId: number | string) { +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()}/uetk/teikimo-anketos/${formId}`, + typeText: formTypeTranslates[formType] || 'teikimo', + objectName, }, }); } @@ -48,12 +66,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; @@ -64,6 +76,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(), @@ -71,7 +89,7 @@ export function notifyOnFormUpdate( TemplateModel: { title: updateType, titleText: updateType.toLowerCase(), - typeText: formTypeTranlates[formType] || 'teikimo', + typeText: formTypeTranslates[formType] || 'teikimo', objectName, actionUrl: `${hostUrl(isAdmin)}/${path}/${formId}`, }, From 8a41ed8575548932f1b1aa2d492bb073689a1b15 Mon Sep 17 00:00:00 2001 From: LWangllix Date: Thu, 18 Jan 2024 20:46:32 +0200 Subject: [PATCH 4/8] undo mail --- mixins/database.mixin.ts | 18 +++++++++++++++--- services/forms.service.ts | 1 - services/users.service.ts | 16 +++------------- utils/mails.ts | 3 +-- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/mixins/database.mixin.ts b/mixins/database.mixin.ts index ddd50ea..c4784fb 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 = { @@ -46,6 +46,18 @@ export default function (opts: any = {}) { return; }, + async update(ctx: any) { + return this.updateEntity( + ctx, + { + ...ctx.params, + }, + { + ...ctx.options, + } + ); + }, + async removeAllEntities(ctx: any) { return await this.clearEntities(ctx); }, diff --git a/services/forms.service.ts b/services/forms.service.ts index 696ce8c..0260d23 100644 --- a/services/forms.service.ts +++ b/services/forms.service.ts @@ -233,7 +233,6 @@ const populatePermissions = (field: string) => { const localUser: User = await ctx.call('users.findOrCreate', { authUser: assigneeAuthUser, update: true, - hideAdmins: false, }); return localUser.id; diff --git a/services/users.service.ts b/services/users.service.ts index c54f7f4..526574d 100644 --- a/services/users.service.ts +++ b/services/users.service.ts @@ -320,7 +320,6 @@ export default class UsersService extends moleculer.Service { type: 'boolean', default: false, }, - firstName: { type: 'string', optional: true, @@ -339,16 +338,7 @@ export default class UsersService extends moleculer.Service { }, }, }) - async findOrCreate( - ctx: Context<{ - authUser: any; - firstName?: string; - lastName?: string; - email?: string; - phone?: string; - update?: boolean; - }> - ) { + async findOrCreate(ctx: any) { const { authUser, update, firstName, lastName, email, phone } = ctx.params; if (!authUser || !authUser.id) return; @@ -380,8 +370,8 @@ export default class UsersService extends moleculer.Service { }; if (user?.id) { - return this.updateEntity( - ctx, + return ctx.call( + 'users.update', { id: user.id, ...dataToSave, diff --git a/utils/mails.ts b/utils/mails.ts index ef97a14..cfaa040 100644 --- a/utils/mails.ts +++ b/utils/mails.ts @@ -12,8 +12,7 @@ const sender = 'noreply@biip.lt'; export function emailCanBeSent() { if (!client) return false; - return true; - //return ['production'].includes(process.env.NODE_ENV); + return ['production'].includes(process.env.NODE_ENV); } function hostUrl(isAdmin: boolean = false) { From 8db8ba5a1a151e145a2acbd65a39d81cf190e0f4 Mon Sep 17 00:00:00 2001 From: LWangllix Date: Mon, 22 Jan 2024 12:02:23 +0200 Subject: [PATCH 5/8] refactor --- services/forms.service.ts | 102 ++++++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 42 deletions(-) diff --git a/services/forms.service.ts b/services/forms.service.ts index 0260d23..1308dbb 100644 --- a/services/forms.service.ts +++ b/services/forms.service.ts @@ -36,7 +36,6 @@ import { Tenant } from './tenants.service'; import { User, USERS_DEFAULT_SCOPES, - USERS_WITHOUT_AUTH_SCOPES, USERS_WITHOUT_NOT_ADMINS_SCOPE, UserType, } from './users.service'; @@ -80,17 +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[]) { + return async function ( + ctx: Context<{}, UserAuthMeta>, + _values: any, + forms: any[] + ) { const { user, profile, authUser } = ctx?.meta; - return forms.map((form: any) => { - const editingPermissions = this.hasPermissionToEdit( - form, - user, - authUser, - profile - ); - return !!editingPermissions[field]; - }); + return await Promise.all( + forms.map(async (form: any) => { + const editingPermissions = await this.hasPermissionToEdit( + ctx, + form, + user, + authUser, + profile + ); + return !!editingPermissions[field]; + }) + ); }; }; @@ -353,28 +359,20 @@ export default class FormsService extends moleculer.Service { @Method async validateAssignee({ ctx, value, entity }: FieldHookCallback) { - const { user, authUser } = ctx?.meta; + const { user, authUser, profile } = ctx?.meta; if (!entity?.id || !user?.id || entity.assigneeId === value) return true; - const newAssignee = Number(value); - const prevAssigneeId = Number(entity.assigneeId); - const userIsCreator = Number(entity.createdBy) === newAssignee; - - if (authUser.type === UserType.ADMIN && !authUser.adminOfGroups.length) { - const assignedToHimself = Number(user.id) === Number(prevAssigneeId); - - if (!!newAssignee && !!prevAssigneeId) { - return 'Assignee already exists.'; - } else if (!newAssignee && !assignedToHimself) { - return 'Cannot unassign others.'; - } - } + const editingPermissions = await this.hasPermissionToEdit( + ctx, + entity, + user, + authUser, + profile + ); - if (!newAssignee && !prevAssigneeId) { - return 'Already unassigned.'; - } else if (!!newAssignee && userIsCreator) { - return 'Cannot assign to creator.'; + if (!editingPermissions.assign) { + return 'Assignee cannot be set.'; } return true; @@ -465,7 +463,7 @@ export default class FormsService extends moleculer.Service { } @Method - validateStatus({ ctx, value, entity }: FieldHookCallback) { + async validateStatus({ ctx, value, entity }: FieldHookCallback) { const { user, profile, authUser } = ctx.meta; if (!value || !user?.id) return true; @@ -482,7 +480,8 @@ export default class FormsService extends moleculer.Service { return newStatuses.includes(value) || error; } - const editingPermissions = this.hasPermissionToEdit( + const editingPermissions = await this.hasPermissionToEdit( + ctx, entity, user, authUser, @@ -499,16 +498,17 @@ 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; assign: boolean; - } { + }> { const invalid = { edit: false, validate: false, assign: false }; const tenant = form.tenant || form.tenantId; @@ -536,23 +536,41 @@ export default class FormsService extends moleculer.Service { (isCreatedByTenant || isCreatedByUser); const isSuperAdminOrAssignee = - authUser.type === UserType.SUPER_ADMIN || form.assigneeId === user.id; + authUser?.type === UserType.SUPER_ADMIN || form.assigneeId === user.id; const canValidate = isSuperAdminOrAssignee && [FormStatus.CREATED, FormStatus.SUBMITTED].includes(form.status); - const canAssign = - !isCreatedByUser && - authUser.type !== UserType.USER && - (!isEmpty(authUser.adminOfGroups) || - !form?.assigneeId || - isSuperAdminOrAssignee); + let canAssign = true; + + if (isCreatedByUser || authUser?.type === UserType.USER) { + canAssign = false; + } + + 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: true, + assign: canAssign, }; } From 7c63c6fcf07232083210f929f4bb4e2523245ebc Mon Sep 17 00:00:00 2001 From: LWangllix Date: Mon, 22 Jan 2024 14:05:38 +0200 Subject: [PATCH 6/8] minor fix --- services/users.service.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/services/users.service.ts b/services/users.service.ts index 526574d..bd848a1 100644 --- a/services/users.service.ts +++ b/services/users.service.ts @@ -338,7 +338,16 @@ export default class UsersService extends moleculer.Service { }, }, }) - async findOrCreate(ctx: any) { + async findOrCreate( + ctx: Context<{ + authUser: any; + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + update?: boolean; + }> + ) { const { authUser, update, firstName, lastName, email, phone } = ctx.params; if (!authUser || !authUser.id) return; @@ -376,7 +385,7 @@ export default class UsersService extends moleculer.Service { id: user.id, ...dataToSave, }, - { scope } + { scope } as any ); } From 574e1a40ef3c0848abf7c44c8d5a3eebb6b51b74 Mon Sep 17 00:00:00 2001 From: LWangllix Date: Mon, 5 Feb 2024 21:03:38 +0200 Subject: [PATCH 7/8] minor fixes --- mixins/database.mixin.ts | 12 --------- services/forms.service.ts | 51 ++++++++++++++++++++++----------------- services/users.service.ts | 11 +++------ utils/mails.ts | 2 +- 4 files changed, 33 insertions(+), 43 deletions(-) diff --git a/mixins/database.mixin.ts b/mixins/database.mixin.ts index c4784fb..12d3b20 100644 --- a/mixins/database.mixin.ts +++ b/mixins/database.mixin.ts @@ -46,18 +46,6 @@ export default function (opts: any = {}) { return; }, - async update(ctx: any) { - return this.updateEntity( - ctx, - { - ...ctx.params, - }, - { - ...ctx.options, - } - ); - }, - async removeAllEntities(ctx: any) { return await this.clearEntities(ctx); }, diff --git a/services/forms.service.ts b/services/forms.service.ts index 1308dbb..f8c3ed8 100644 --- a/services/forms.service.ts +++ b/services/forms.service.ts @@ -225,6 +225,12 @@ const populatePermissions = (field: string) => { 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' ); @@ -232,8 +238,8 @@ const populatePermissions = (field: string) => { (assignee) => assignee.id === Number(value) ); - if (user.type === UserType.USER || !assigneeAuthUser) { - throwBadRequestError('Assignee cannot be set.'); + if (!assigneeAuthUser) { + throwBadRequestError(error); } const localUser: User = await ctx.call('users.findOrCreate', { @@ -399,6 +405,8 @@ export default class FormsService extends moleculer.Service { ], total: 1, page: 1, + pageSize: 10, + totalPages: 1, }; return await ctx.call('auth.users.list', { @@ -410,7 +418,6 @@ export default class FormsService extends moleculer.Service { }, }, fields: ['id', 'firstName', 'lastName', 'phone', 'email', 'type'], - pageSize: 99999, }); } @@ -544,7 +551,7 @@ export default class FormsService extends moleculer.Service { let canAssign = true; - if (isCreatedByUser || authUser?.type === UserType.USER) { + if (isCreatedByUser || user?.type === UserType.USER) { canAssign = false; } @@ -685,6 +692,24 @@ export default class FormsService extends moleculer.Service { async 'forms.updated'(ctx: Context>) { const { oldData: prevForm, data: form } = ctx.params; + if (prevForm?.status !== form.status) { + const { comment } = ctx.options?.parentCtx?.params as any; + const typesByStatus = { + [FormStatus.SUBMITTED]: FormHistoryTypes.UPDATED, + [FormStatus.REJECTED]: FormHistoryTypes.REJECTED, + [FormStatus.RETURNED]: FormHistoryTypes.RETURNED, + [FormStatus.APPROVED]: FormHistoryTypes.APPROVED, + }; + + await ctx.call('forms.histories.create', { + form: form.id, + comment, + type: typesByStatus[form.status], + }); + + await this.sendNotificationOnStatusChange(form); + } + if (!!form?.assignee && prevForm?.assignee !== form?.assignee) { const assignee: User = await ctx.call('users.resolve', { id: form.assignee, @@ -706,24 +731,6 @@ export default class FormsService extends moleculer.Service { object.id ); } - - if (prevForm?.status !== form.status) { - const { comment } = ctx.options?.parentCtx?.params as any; - const typesByStatus = { - [FormStatus.SUBMITTED]: FormHistoryTypes.UPDATED, - [FormStatus.REJECTED]: FormHistoryTypes.REJECTED, - [FormStatus.RETURNED]: FormHistoryTypes.RETURNED, - [FormStatus.APPROVED]: FormHistoryTypes.APPROVED, - }; - - await ctx.call('forms.histories.create', { - form: form.id, - comment, - type: typesByStatus[form.status], - }); - - await this.sendNotificationOnStatusChange(form); - } } @Event() diff --git a/services/users.service.ts b/services/users.service.ts index bd848a1..d9850fd 100644 --- a/services/users.service.ts +++ b/services/users.service.ts @@ -379,14 +379,9 @@ export default class UsersService extends moleculer.Service { }; if (user?.id) { - return ctx.call( - 'users.update', - { - id: user.id, - ...dataToSave, - }, - { scope } as any - ); + return this.updateEntity(ctx, dataToSave, { + scope, + }); } // let user to customize his phone and email diff --git a/utils/mails.ts b/utils/mails.ts index cfaa040..76a2d97 100644 --- a/utils/mails.ts +++ b/utils/mails.ts @@ -41,7 +41,7 @@ export function notifyFormAssignee( To: email.toLowerCase(), TemplateId: 34487978, TemplateModel: { - actionUrl: `${hostUrl()}/uetk/teikimo-anketos/${formId}`, + actionUrl: `${hostUrl(true)}/uetk/teikimo-anketos/${formId}`, typeText: formTypeTranslates[formType] || 'teikimo', objectName, }, From 2d612bc29ac5009b7e856c7383130f5144366621 Mon Sep 17 00:00:00 2001 From: LWangllix Date: Thu, 8 Feb 2024 23:37:34 +0200 Subject: [PATCH 8/8] fix --- services/users.service.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/services/users.service.ts b/services/users.service.ts index d9850fd..8e39543 100644 --- a/services/users.service.ts +++ b/services/users.service.ts @@ -379,9 +379,13 @@ export default class UsersService extends moleculer.Service { }; if (user?.id) { - return this.updateEntity(ctx, dataToSave, { - scope, - }); + return this.updateEntity( + ctx, + { id: user.id, ...dataToSave }, + { + scope, + } + ); } // let user to customize his phone and email