Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

This commit is contained in:
Artyom Savchenko
2026-07-16 16:04:01 +07:00
12 changed files with 243 additions and 95 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
"containerd-snapshotter": true
}
}
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 1
filter: tree:0
+26 -26
View File
@@ -68,7 +68,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.ref || github.ref }}
fetch-depth: 0
@@ -78,7 +78,7 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-node-platform
with:
@@ -127,7 +127,7 @@ jobs:
run: node common/scripts/install-run-rush.js validate
- name: Cache build results
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-build-results
with:
@@ -139,7 +139,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
@@ -150,7 +150,7 @@ jobs:
node-version-file: '.nvmrc'
- name: Cache build results
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-build-results
with:
@@ -165,7 +165,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: recursive
@@ -173,7 +173,7 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Cache build results
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-build-results
with:
@@ -196,7 +196,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
@@ -205,7 +205,7 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Cache build results
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-build-results
with:
@@ -234,7 +234,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
@@ -243,7 +243,7 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-node-platform
with:
@@ -322,7 +322,7 @@ jobs:
name: playwright-results
path: ./tests/sanity/playwright-report/
# - name: Get Allure history
# uses: actions/checkout@v6
# uses: actions/checkout@v7
# if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
# continue-on-error: true
# with:
@@ -366,7 +366,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
@@ -375,7 +375,7 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-node-platform
with:
@@ -463,7 +463,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
@@ -472,7 +472,7 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-node-platform
with:
@@ -544,7 +544,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
@@ -553,7 +553,7 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-node-platform
with:
@@ -641,7 +641,7 @@ jobs:
timeout-minutes: 60
steps:
# https://github.com/actions/runner-images/issues/2840#issuecomment-790492173
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
@@ -674,7 +674,7 @@ jobs:
with:
version: 10
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-node-platform
with:
@@ -744,7 +744,7 @@ jobs:
runs-on: macos-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
@@ -755,7 +755,7 @@ jobs:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: node
with:
@@ -847,7 +847,7 @@ jobs:
runs-on: macos-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
filter: tree:0
@@ -858,7 +858,7 @@ jobs:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: node
with:
@@ -949,7 +949,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.ref || github.ref }}
fetch-depth: 0
@@ -957,7 +957,7 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-node-platform
with:
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.ref }}
fetch-depth: 0
@@ -23,7 +23,7 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Cache node modules
uses: actions/cache@v5
uses: actions/cache@v6
env:
cache-name: cache-node-platform
with:
+27
View File
@@ -0,0 +1,27 @@
# Security Policy
## Reporting a Vulnerability
We take security seriously. If you discover a security vulnerability in the
Huly Platform, please report it privately — **do not open a public issue.**
Use GitHub's private vulnerability reporting:
1. Go to the [Security tab](https://github.com/hcengineering/platform/security) of this repository.
2. Click **"Report a vulnerability"**.
3. Fill in the advisory with as much detail as possible (affected component,
reproduction steps, impact, suggested fix if any).
We'll acknowledge your report, investigate, and coordinate a fix and
disclosure timeline with you before any public disclosure.
## Supported Versions
Security fixes are applied to the latest release on the `main`/`develop`
branch. Older versions are not guaranteed to receive patches.
## Scope
This policy covers the code in this repository. Vulnerabilities in
third-party dependencies should be reported upstream, though we're happy to
hear about them too if they affect the Platform directly.
+53 -1
View File
@@ -22,7 +22,8 @@ import {
DOMAIN_MODEL_TX,
type TxCUD
} from '@hcengineering/core'
import { type Department } from '@hcengineering/hr'
import contact, { type Employee, type Person } from '@hcengineering/contact'
import { type Department, type Staff } from '@hcengineering/hr'
import {
migrateSpace,
tryMigrate,
@@ -32,6 +33,7 @@ import {
type MigrationUpgradeClient
} from '@hcengineering/model'
import core, { DOMAIN_SPACE, getAccountsFromTxes } from '@hcengineering/model-core'
import { DOMAIN_CONTACT } from '@hcengineering/model-contact'
import hr, { DOMAIN_HR, hrId } from './index'
@@ -103,6 +105,52 @@ async function migrateDepartmentMembersToEmployee (client: MigrationClient): Pro
}
}
async function rebuildDepartmentMembersFromStaff (client: MigrationClient): Promise<void> {
const departments = await client.find<Department>(DOMAIN_HR, { _class: hr.class.Department })
const departmentById = new Map(departments.map((department) => [department._id, department]))
const membersByDepartment = new Map<Ref<Department>, Set<Ref<Employee>>>(
departments.map((department) => [department._id, new Set<Ref<Employee>>()])
)
const getHierarchy = (department: Ref<Department>): Ref<Department>[] => {
const result: Ref<Department>[] = []
let current: Ref<Department> | undefined = department
while (current !== undefined) {
if (result.includes(current)) {
break
}
const currentDepartment = departmentById.get(current)
if (currentDepartment === undefined) {
break
}
result.push(currentDepartment._id)
current = currentDepartment.parent ?? (currentDepartment._id !== hr.ids.Head ? hr.ids.Head : undefined)
}
return result
}
const persons = await client.find<Person>(DOMAIN_CONTACT, { _class: contact.class.Person })
for (const person of persons) {
const staff = client.hierarchy.asIf<Person, Staff>(person, hr.mixin.Staff)
if (staff?.department === undefined || !staff.active) {
continue
}
for (const department of getHierarchy(staff.department)) {
membersByDepartment.get(department)?.add(person._id as Ref<Employee>)
}
}
for (const department of departments) {
const members = Array.from(membersByDepartment.get(department._id) ?? [])
await client.update(DOMAIN_HR, { _id: department._id }, { members })
}
}
export const hrOperation: MigrateOperation = {
async migrate (client: MigrationClient, mode): Promise<void> {
await tryMigrate(mode, client, hrId, [
@@ -120,6 +168,10 @@ export const hrOperation: MigrateOperation = {
{
state: 'migrateDepartmentMembersToEmployee',
func: migrateDepartmentMembersToEmployee
},
{
state: 'rebuildDepartmentMembersFromStaff',
func: rebuildDepartmentMembersFromStaff
}
])
},
+8
View File
@@ -41,6 +41,14 @@ export function createModel (builder: Builder): void {
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverHr.trigger.OnDepartmentUpdate,
txMatch: {
objectClass: hr.class.Department,
_class: core.class.TxUpdateDoc
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverHr.trigger.OnRequestCreate,
txMatch: {
@@ -77,7 +77,8 @@
$: dragPersonId = dragPerson?._id
$: values = allEmployees.filter((it) => it.department === value._id && it._id !== dragPersonId)
$: members = new Set<Ref<Staff>>(value.members as Ref<Staff>[])
$: values = allEmployees.filter((it) => members.has(it._id) && it._id !== dragPersonId)
$: dragging = value._id === dragOver?._id && dragPersonId !== undefined
@@ -13,10 +13,10 @@
// limitations under the License.
-->
<script lang="ts">
import contact from '@hcengineering/contact'
import contact, { Employee } from '@hcengineering/contact'
import { UsersPopup } from '@hcengineering/contact-resources'
import { Ref, WithLookup } from '@hcengineering/core'
import { Department, Staff } from '@hcengineering/hr'
import { Department } from '@hcengineering/hr'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Button, IconAdd, Label, Scroller, Section, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import { Viewlet, ViewletPreference } from '@hcengineering/view'
@@ -42,7 +42,7 @@
}
)
function add (e: MouseEvent) {
function add (e: MouseEvent): void {
showPopup(
UsersPopup,
{
@@ -50,19 +50,15 @@
docQuery: {
active: true
},
ignoreUsers: memberItems.map((it) => it._id)
ignoreUsers: members
},
eventToHTMLElement(e),
(res) => addMember(client, res, value)
)
}
let memberItems: Staff[] = []
const membersQuery = createQuery()
$: membersQuery.query(hr.mixin.Staff, { department: objectId }, (result) => {
memberItems = result
})
let members: Array<Ref<Employee>> = []
$: members = value?.members ?? []
let preference: ViewletPreference | undefined
let loading = false
@@ -85,14 +81,14 @@
</svelte:fragment>
<svelte:fragment slot="content">
{#if (value?.members.length ?? 0) > 0}
{#if members.length > 0}
<Scroller>
<Table
_class={hr.mixin.Staff}
config={preference?.config ?? viewlet?.config ?? []}
options={viewlet?.options}
query={{ department: objectId }}
loadingProps={{ length: value?.members.length ?? 0 }}
query={{ _id: { $in: members } }}
loadingProps={{ length: members.length }}
/>
</Scroller>
{:else}
@@ -50,8 +50,8 @@
? getEndDate(currentDate.getFullYear(), 11)
: getEndDate(currentDate.getFullYear(), currentDate.getMonth())
$: departments = [department, ...getDescendants(department, descendants, new Set())]
$: staffIdsForOpenedDepartments = staff.filter((p) => departments.includes(p.department)).map((p) => p._id)
$: departmentMembers = new Set<Ref<Staff>>((departmentById.get(department)?.members ?? []) as Ref<Staff>[])
$: staffIdsForOpenedDepartments = staff.filter((p) => departmentMembers.has(p._id)).map((p) => p._id)
const lq = createQuery()
const typeQuery = createQuery()
@@ -81,23 +81,7 @@
let employeeRequests = new Map<Ref<Staff>, Request[]>()
function getDescendants (
department: Ref<Department>,
descendants: Map<Ref<Department>, Department[]>,
visited: Set<string>
): Ref<Department>[] {
const res = (descendants.get(department) ?? []).map((p) => p._id)
for (const department of res) {
const has = visited.has(department)
if (!has) {
visited.add(department)
res.push(...getDescendants(department, descendants, visited))
}
}
return res
}
let departmentStaff: Staff[]
let departmentStaff: Staff[] = []
let editableList: Ref<Employee>[] = []
function update (staffIdsForOpenedDepartments: Ref<Staff>[], startDate: Date, endDate: Date) {
@@ -136,14 +120,11 @@
function pushChilds (
department: Ref<Department>,
departmentStaff: Staff[],
descendants: Map<Ref<Department>, Department[]>
departmentById: Map<Ref<Department>, Department>
): void {
const staff = departmentStaff.filter((p) => p.department === department)
const members = new Set<Ref<Staff>>((departmentById.get(department)?.members ?? []) as Ref<Staff>[])
const staff = departmentStaff.filter((p) => members.has(p._id))
editableList.push(...staff.filter((p) => !editableList.includes(p._id)).map((p) => p._id))
const desc = descendants.get(department) ?? []
for (const des of desc) {
pushChilds(des._id, departmentStaff, descendants)
}
}
function isEditable (department: Department): boolean {
@@ -155,17 +136,17 @@
department: Ref<Department>,
departmentStaff: Staff[],
descendants: Map<Ref<Department>, Department[]>
) {
): void {
const dep = departmentById.get(department)
if (dep === undefined) return
if (isEditable(dep)) {
pushChilds(dep._id, departmentStaff, descendants)
pushChilds(dep._id, departmentStaff, departmentById)
} else {
const descendantDepartments = descendants.get(dep._id)
if (descendantDepartments !== undefined) {
for (const department of descendantDepartments) {
if (isEditable(department)) {
pushChilds(department._id, departmentStaff, descendants)
pushChilds(department._id, departmentStaff, departmentById)
} else {
checkDepartmentEditable(departmentById, department._id, departmentStaff, descendants)
}
@@ -178,7 +159,7 @@
departmentById: Map<Ref<Department>, Department>,
departmentStaff: Staff[],
descendants: Map<Ref<Department>, Department[]>
) {
): void {
editableList = [currentEmployee]
checkDepartmentEditable(departmentById, hr.ids.Head, departmentStaff, descendants)
editableList = editableList
@@ -186,15 +167,16 @@
function updateStaff (
staff: Staff[],
departments: Ref<Department>[],
staffIdsForOpenedDepartments: Ref<Staff>[],
descendants: Map<Ref<Department>, Department[]>,
departmentById: Map<Ref<Department>, Department>
) {
departmentStaff = staff.filter((p) => departments.includes(p.department))
): void {
const departmentMembers = new Set(staffIdsForOpenedDepartments)
departmentStaff = staff.filter((p) => departmentMembers.has(p._id))
updateEditableList(departmentById, departmentStaff, descendants)
}
$: updateStaff(staff, departments, descendants, departmentById)
$: updateStaff(staff, staffIdsForOpenedDepartments, descendants, departmentById)
const reportQuery = createQuery()
@@ -287,7 +269,7 @@
}
return map
}
let staffDepartmentMap = new Map()
let staffDepartmentMap = new Map<Ref<Staff>, Department[]>()
$: void getDepartmentsForEmployee(departmentStaff).then((res) => {
staffDepartmentMap = res
})
@@ -295,18 +277,21 @@
function getDepartmentHolidays (department: Ref<Department>): Date[] {
const parents = ancestors.get(department) ?? []
const result = []
const result = new Map<string, Date>()
const addHoliday = (holiday: Date): void => {
result.set(`${holiday.getFullYear()}-${holiday.getMonth()}-${holiday.getDate()}`, holiday)
}
// get own holidays
const holidays = holidaysMap.get(department) ?? []
result.push(...holidays)
holidays.forEach(addHoliday)
// get ancestor holidays
for (const parent of parents) {
const parentHolidays = holidaysMap.get(parent) ?? []
result.push(...parentHolidays)
parentHolidays.forEach(addHoliday)
}
return result
return [...result.values()]
}
</script>
@@ -14,7 +14,8 @@
-->
<script lang="ts">
import { getName } from '@hcengineering/contact'
import contact, { Avatar } from '@hcengineering/contact-resources'
import { Avatar } from '@hcengineering/contact-resources'
import contact from '@hcengineering/contact-resources/src/plugin'
import hr, { Department, Staff } from '@hcengineering/hr'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
+89 -12
View File
@@ -63,6 +63,44 @@ async function getOldDepartment (
return lastDepartment
}
async function getOldDepartmentParent (
currentTx: TxUpdateDoc<Department>,
control: TriggerControl
): Promise<Ref<Department> | undefined> {
const updateTxes = await control.findAll<TxUpdateDoc<Department>>(
control.ctx,
core.class.TxUpdateDoc,
{
objectId: currentTx.objectId,
objectClass: currentTx.objectClass
},
{ sort: { modifiedOn: SortingOrder.Ascending } }
)
let parent: Ref<Department> | undefined
for (const tx of updateTxes) {
if (tx._id === currentTx._id) break
if (tx.operations.parent !== undefined) {
parent = tx.operations.parent
}
}
if (parent !== undefined) return parent
const createTxes = await control.findAll<TxCreateDoc<Department>>(
control.ctx,
core.class.TxCreateDoc,
{
objectId: currentTx.objectId,
objectClass: currentTx.objectClass
},
{ sort: { modifiedOn: SortingOrder.Ascending } }
)
return createTxes[0]?.attributes.parent
}
async function buildHierarchy (_id: Ref<Department>, control: TriggerControl): Promise<Department[]> {
const res: Department[] = []
const ancestors = new Map<Ref<Department>, Ref<Department>>()
@@ -82,7 +120,7 @@ async function buildHierarchy (_id: Ref<Department>, control: TriggerControl): P
}
}
function exlude (first: Ref<Department>[], second: Ref<Department>[]): Ref<Department>[] {
function exclude (first: Ref<Department>[], second: Ref<Department>[]): Ref<Department>[] {
const set = new Set(first)
const res: Ref<Department>[] = []
for (const department of second) {
@@ -96,16 +134,18 @@ function exlude (first: Ref<Department>[], second: Ref<Department>[]): Ref<Depar
function getTxes (
factory: TxFactory,
employees: Ref<Employee>[],
added: Ref<Department>[],
added: Department[],
removed?: Ref<Department>[]
): Tx[] {
const pushTxes = added
.map((dep) =>
employees.map((emp) =>
factory.createTxUpdateDoc(hr.class.Department, core.space.Workspace, dep, {
$push: { members: emp }
})
)
employees
.filter((emp) => !dep.members.includes(emp))
.map((emp) =>
factory.createTxUpdateDoc(hr.class.Department, core.space.Workspace, dep._id, {
$push: { members: emp }
})
)
)
.flat()
if (removed === undefined) return pushTxes
@@ -146,22 +186,58 @@ export async function OnDepartmentStaff (txes: Tx[], control: TriggerControl): P
)
)
}
continue
}
const push = (await buildHierarchy(departmentId, control)).map((p) => p._id)
const push = await buildHierarchy(departmentId, control)
if (lastDepartment === undefined) {
result.push(...getTxes(control.txFactory, [employee], push))
} else {
let removed = (await buildHierarchy(lastDepartment, control)).map((p) => p._id)
const added = exlude(removed, push)
removed = exlude(push, removed)
result.push(...getTxes(control.txFactory, [employee], added, removed))
const removedDepartments = await buildHierarchy(lastDepartment, control)
const removed = removedDepartments.map((p) => p._id)
const pushIds = push.map((p) => p._id)
const added = push.filter((p) => !removed.includes(p._id))
const removedIds = exclude(pushIds, removed)
result.push(...getTxes(control.txFactory, [employee], added, removedIds))
}
}
}
return result
}
/**
* @public
*/
export async function OnDepartmentUpdate (txes: Tx[], control: TriggerControl): Promise<Tx[]> {
const result: Tx[] = []
for (const tx of txes) {
const ctx = tx as TxUpdateDoc<Department>
if (ctx.operations.parent === undefined) continue
const department = await control.findAll(control.ctx, hr.class.Department, { _id: ctx.objectId })
const currentDepartment = department[0]
if (currentDepartment === undefined) continue
const members = currentDepartment.members
if (members.length === 0) continue
const oldParent = await getOldDepartmentParent(ctx, control)
if (oldParent === ctx.operations.parent) continue
const newParent = currentDepartment.parent
const oldHierarchy = oldParent !== undefined ? await buildHierarchy(oldParent, control) : []
const newHierarchy = newParent !== undefined ? await buildHierarchy(newParent, control) : []
const oldHierarchyIds = oldHierarchy.map((p) => p._id)
const newHierarchyIds = newHierarchy.map((p) => p._id)
const added = newHierarchy.filter((p) => !oldHierarchyIds.includes(p._id))
const removed = oldHierarchy.filter((p) => !newHierarchyIds.includes(p._id)).map((p) => p._id)
result.push(...getTxes(control.txFactory, members, added, removed))
}
return result
}
/**
* @public
*/
@@ -438,6 +514,7 @@ export default async () => ({
OnRequestUpdate,
OnRequestRemove,
OnDepartmentStaff,
OnDepartmentUpdate,
OnDepartmentRemove,
OnEmployeeDeactivate,
OnPublicHolidayCreate
+1
View File
@@ -31,6 +31,7 @@ export default plugin(serverHrId, {
OnEmployee: '' as Resource<TriggerFunc>,
OnEmployeeDeactivate: '' as Resource<TriggerFunc>,
OnDepartmentStaff: '' as Resource<TriggerFunc>,
OnDepartmentUpdate: '' as Resource<TriggerFunc>,
OnDepartmentRemove: '' as Resource<TriggerFunc>,
OnRequestCreate: '' as Resource<TriggerFunc>,
OnRequestUpdate: '' as Resource<TriggerFunc>,