@things-factory/organization 10.1.228 → 10.1.230

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist-client/component/department-selector.js +0 -7
  2. package/dist-client/component/department-selector.js.map +1 -1
  3. package/dist-client/pages/department/department-list-page.js +1 -1
  4. package/dist-client/pages/department/department-list-page.js.map +1 -1
  5. package/dist-client/tsconfig.tsbuildinfo +1 -1
  6. package/dist-server/role-tasks.js +2 -0
  7. package/dist-server/role-tasks.js.map +1 -1
  8. package/dist-server/service/department/department-query.d.ts +1 -0
  9. package/dist-server/service/department/department-query.js +26 -3
  10. package/dist-server/service/department/department-query.js.map +1 -1
  11. package/dist-server/service/department/department.js +3 -1
  12. package/dist-server/service/department/department.js.map +1 -1
  13. package/dist-server/service/employee-profile/employee-profile-document.d.ts +10 -4
  14. package/dist-server/service/employee-profile/employee-profile-document.js +84 -39
  15. package/dist-server/service/employee-profile/employee-profile-document.js.map +1 -1
  16. package/dist-server/service/employee-profile/employee-profile-mutation.js.map +1 -1
  17. package/dist-server/service/employee-profile/employee-profile-rules.d.ts +12 -0
  18. package/dist-server/service/employee-profile/employee-profile-rules.js +25 -0
  19. package/dist-server/service/employee-profile/employee-profile-rules.js.map +1 -1
  20. package/dist-server/service/employee-profile/employee-profile.d.ts +1 -0
  21. package/dist-server/service/employee-profile/employee-profile.js +14 -1
  22. package/dist-server/service/employee-profile/employee-profile.js.map +1 -1
  23. package/dist-server/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +2 -2
  25. package/tests/department-open-fields.test.ts +72 -0
  26. package/tests/employee-profile-db.test.ts +14 -0
  27. package/tests/employee-profile-document.test.ts +66 -3
  28. package/translations/en.json +2 -0
  29. package/translations/es.json +2 -0
  30. package/translations/ja.json +2 -0
  31. package/translations/ko.json +2 -0
  32. package/translations/ms.json +2 -0
  33. package/translations/zh.json +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/organization",
3
- "version": "10.1.228",
3
+ "version": "10.1.230",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -43,5 +43,5 @@
43
43
  "@things-factory/shell": "^10.1.228",
44
44
  "exceljs": "4.2.0"
45
45
  },
46
- "gitHead": "fc4ede44617418df8701e83b83d177db4142a8f3"
46
+ "gitHead": "27f533e9f72834aac95fd6e7287e5ae1862164da"
47
47
  }
@@ -0,0 +1,72 @@
1
+ import { readFileSync } from 'fs'
2
+ import path from 'path'
3
+
4
+ import { ROLE_TASKS } from '../server/role-tasks'
5
+
6
+ /**
7
+ * Every seat reads the departments; the people in them stay the roster's (ADR-0139, the user 2026-10-29).
8
+ *
9
+ * A sales seat saving an opportunity was refused `department:query`: the opportunity names a department. The
10
+ * department's own fields say nothing about anybody; `manager` and `members` lead to employee records. So those two
11
+ * ask `employee:query` on the field, and the list doors are searched, filtered and sorted only by the open fields -
12
+ * filtering by a closed one would tell, by whether a department stays in the list, what it holds.
13
+ */
14
+ const read = (file: string) => readFileSync(path.resolve(__dirname, '..', file), 'utf-8')
15
+ const CLOSED = ['manager', 'members']
16
+
17
+ describe('the department list every seat reads', () => {
18
+ const entity = read('server/service/department/department.ts')
19
+ const doors = read('server/service/department/department-query.ts')
20
+
21
+ it.each(CLOSED)('★ %s asks employee:query on the declared field', field => {
22
+ expect(entity).toMatch(
23
+ new RegExp(`@Directive\\('@privilege\\(category: "employee", privilege: "query", domainOwnerGranted: true\\)'\\)\\n @Field\\([^\\n]*\\)\\n ${field}\\?:`)
24
+ )
25
+ })
26
+
27
+ it.each(CLOSED)('★ %s is nullable, so a seat refused it still gets the list above it', field => {
28
+ expect(entity).toMatch(new RegExp(` @Field\\([^\\n]*\\{ nullable: true \\}\\)\\n ${field}\\?:`))
29
+ })
30
+
31
+ it("★ members are resolved - the department's own employees in its domain - so a seat that may read them gets them", () => {
32
+ const resolver = doors.slice(doors.indexOf('async members('), doors.indexOf('@FieldResolver', doors.indexOf('async members(')))
33
+ expect(doors).toMatch(/@FieldResolver\(type => \[Employee\], \{ nullable: true \}\)\n async members\(/)
34
+ expect(resolver).toMatch(/domain: \{ id: department\.domainId \}, department: \{ id: department\.id \}/)
35
+ })
36
+
37
+ it('★ no other field of a department asks a privilege', () => {
38
+ const asked = [...entity.matchAll(/@privilege\([^\n]*\n @Field\([^\n]*\)\n (\w+)\?:/g)].map(match => match[1])
39
+ expect(asked.sort()).toEqual([...CLOSED].sort())
40
+ })
41
+
42
+ it('★ every list door reads by the same rules, and those rules name no closed field', () => {
43
+ const rules = doors.slice(doors.indexOf('const DEPARTMENT_LIST_RULES = {'), doors.indexOf('}', doors.indexOf('const DEPARTMENT_LIST_RULES = {')))
44
+ for (const key of ['searchables', 'filterables', 'sortables']) expect([key, rules.includes(`${key}: [`)]).toEqual([key, true])
45
+ for (const field of [...CLOSED, 'managerId']) expect([field, rules.includes(`'${field}'`)]).toEqual([field, false])
46
+ for (const door of ['async departmentRoots(', 'async departments(', 'async children(']) {
47
+ const body = doors.slice(doors.indexOf(door), doors.indexOf('getManyAndCount', doors.indexOf(door)))
48
+ expect([door, body.includes('...DEPARTMENT_LIST_RULES')]).toEqual([door, true])
49
+ }
50
+ expect(doors.match(/searchables: \[/g)).toHaveLength(1)
51
+ })
52
+
53
+ it('★ the department picker asks nothing a seat without employee:query is refused', () => {
54
+ const picker = read('client/component/department-selector.ts')
55
+ const fragment = picker.slice(picker.indexOf('fragment SubDepartment on Department'), picker.indexOf('`', picker.indexOf('fragment SubDepartment on Department')))
56
+ for (const field of CLOSED) expect([field, new RegExp(`^\\s+${field}\\b`, 'm').test(fragment)]).toEqual([field, false])
57
+ })
58
+
59
+ it('★ the department table does not offer sorting by its head', () => {
60
+ const screen = read('client/pages/department/department-list-page.ts')
61
+ const column = screen.slice(screen.indexOf("name: 'manager',"), screen.indexOf("type: 'checkbox'", screen.indexOf("name: 'manager',")))
62
+ expect(column).toMatch(/\n sortable: false,\n width: 120/)
63
+ })
64
+
65
+ it('★ reading the departments is a task of its own, holding department:query alone, named in every language', () => {
66
+ expect(ROLE_TASKS.find(task => task.id === 'organization.departments-reading')?.grants).toEqual([{ category: 'department', axes: ['query'] }])
67
+ for (const lang of ['en', 'ko', 'ja', 'zh', 'ms', 'es']) {
68
+ const words = JSON.parse(read(`translations/${lang}.json`))
69
+ expect([lang, !!words['task.organization.departments-reading.name'], !!words['task.organization.departments-reading.description']]).toEqual([lang, true, true])
70
+ }
71
+ })
72
+ })
@@ -123,6 +123,20 @@ describe("an employee's own profile", () => {
123
123
  expect(await dataSource.getRepository(organization.EmployeeProfile).count()).toBe(1)
124
124
  })
125
125
 
126
+ it('★ read back newest first, as written by its end - the stored rows keep the order written', async () => {
127
+ const lee = await person('lee')
128
+ const saved = await ask(lee, SAVE, {
129
+ profile: {
130
+ projects: [{ name: 'old', startedOn: '2015-01', endedOn: '2016-01' }, { name: 'now', startedOn: '2024-01' }, { name: 'mid', startedOn: '2019-01', endedOn: '2021-06' }]
131
+ }
132
+ })
133
+ expect(saved.errors).toBeUndefined()
134
+ expect(saved.data.saveMyEmployeeProfile.projects.map((row: any) => row.name)).toEqual(['now', 'mid', 'old'])
135
+ expect((await ask(lee, MINE)).data.myEmployeeProfile.projects.map((row: any) => row.name)).toEqual(['now', 'mid', 'old'])
136
+ const [stored] = await dataSource.query(`SELECT projects FROM employee_profiles WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT 1`)
137
+ expect(JSON.parse(stored.projects).map((row: any) => row.name)).toEqual(['old', 'now', 'mid'])
138
+ })
139
+
126
140
  it('★ adding from a résumé takes nothing away, and reaches only one\'s own', async () => {
127
141
  const kim = await person('kim')
128
142
  const park = await person('park')
@@ -1,6 +1,8 @@
1
1
  import { Workbook } from 'exceljs'
2
2
 
3
3
  import {
4
+ COLUMN_WIDTHS,
5
+ ROSTER_WIDTHS,
4
6
  careerLengthOf,
5
7
  careerMonths,
6
8
  heightOf,
@@ -21,6 +23,7 @@ import {
21
23
  sheetNamesOf,
22
24
  type ProfileSheetWords
23
25
  } from '../server/service/employee-profile/employee-profile-document'
26
+ import { inShowingOrder, newestFirst } from '../server/service/employee-profile/employee-profile-rules'
24
27
 
25
28
  /** The profiles a quotation attaches - one layout, painted as Excel and as HTML for PDF (ADR-0169 decision 5). */
26
29
  const COUNTS: Record<string, (...args: number[]) => string> = {
@@ -108,14 +111,59 @@ describe('one layout of a staffing profile', () => {
108
111
  expect(periodOf(undefined, undefined, 'now')).toBe('')
109
112
  })
110
113
 
111
- it('a roster: how many and their experience on average, then one line a person in the order picked', () => {
114
+ it('★ newest first, as a résumé reads: by the end, one still going on top, then by the start; undated rows last', () => {
115
+ const careers = [
116
+ { company: 'A', startedOn: '2010-03', endedOn: '2014-02' },
117
+ { company: 'undated' },
118
+ { company: 'B', startedOn: '2014-03', endedOn: '2019-12' },
119
+ { company: 'now', startedOn: '2020-01' },
120
+ { company: 'C', startedOn: '2016-01', endedOn: '2019-12' }
121
+ ]
122
+ expect(newestFirst(careers).map(row => row.company)).toEqual(['now', 'C', 'B', 'A', 'undated'])
123
+ expect(careers[0].company).toBe('A')
124
+ expect(newestFirst([{ name: 'old', acquiredOn: '2015-05-01' }, { name: 'new', acquiredOn: '2023-01-10' }]).map(row => row.name)).toEqual(['new', 'old'])
125
+ })
126
+
127
+ it('★ a profile is shown in that order wherever it is read - put in it as it is loaded, the stored order untouched', () => {
128
+ const profile = inShowingOrder({ careers: [{ company: 'old', startedOn: '2001-01', endedOn: '2005-01' }, { company: 'now', startedOn: '2010-01' }], projects: null as any, skills: ['b', 'a'] } as any)
129
+ expect(profile.careers.map((row: any) => row.company)).toEqual(['now', 'old'])
130
+ expect(profile.projects).toBeNull()
131
+ expect(profile.skills).toEqual(['b', 'a'])
132
+ const entity = require('fs').readFileSync(require('path').join(__dirname, '../server/service/employee-profile/employee-profile.ts'), 'utf8')
133
+ expect(entity).toMatch(/^ @AfterLoad\(\)\n showNewestFirst\(\) \{\n inShowingOrder\(this\)/m)
134
+ })
135
+
136
+ it('★ every dated table of the page reads newest first', () => {
137
+ const person = {
138
+ ...KIM,
139
+ profile: {
140
+ projects: [{ name: 'P-old', startedOn: '2018-01', endedOn: '2018-06' }, { name: 'P-new', startedOn: '2024-01' }],
141
+ careers: [{ company: 'C-old', startedOn: '2010-01', endedOn: '2012-01' }, { company: 'C-new', startedOn: '2012-02', endedOn: '2015-01' }],
142
+ certificates: [{ name: 'T-old', acquiredOn: '2011-01-01' }, { name: 'T-new', acquiredOn: '2020-01-01' }],
143
+ educations: [{ school: 'E-old', startedOn: '2001-03', endedOn: '2005-02' }, { school: 'E-new', startedOn: '2005-03', endedOn: '2007-02' }]
144
+ }
145
+ }
146
+ const text = profileSheetLayout(person as any, WORDS, 'Co', TODAY).flatMap(line => line.cells.map(cell => cell.value)).join('|')
147
+ for (const [newer, older] of [['P-new', 'P-old'], ['C-new', 'C-old'], ['T-new', 'T-old'], ['E-new', 'E-old']]) expect(text.indexOf(newer)).toBeLessThan(text.indexOf(older))
148
+ })
149
+
150
+ it('a roster: how many and their experience on average, then two lines a person in the order picked', () => {
112
151
  const lines = rosterLayout([KIM, { name: '박지민', hiredOn: '2025-10-01' }], WORDS, 'C', TODAY)
113
152
  expect(valuesOf(lines[0])).toEqual(['C', '<writtenOn> 2026.10.28'])
114
153
  expect(valuesOf(lines[1])).toEqual(['<rosterTitle>'])
115
154
  const average = Math.round((careerMonths(KIM, TODAY) + 13) / 2)
116
155
  expect(valuesOf(lines[2])).toEqual([`2people · <averageCareer> ${Math.floor(average / 12)}y${average % 12}m`])
117
- expect(valuesOf(lines[5])).toEqual(['<number>', '<name>', '<department>', '<jobPosition>', '<careerLength>', '<skills>'])
118
- expect(lines.slice(6).map(line => valuesOf(line).slice(0, 2))).toEqual([['1', '김하늘'], ['2', '박지민']])
156
+ expect(valuesOf(lines[5])).toEqual(['<number>', '<name>', '<department>', '<jobPosition>', '<careerLength>'])
157
+ expect(lines.slice(6).map(line => valuesOf(line).slice(0, 2))).toEqual([['1', '김하늘'], ['TypeScript, PostgreSQL'], ['2', '박지민']])
158
+ /* The number runs down the person's two lines; the skills sit under the name; none written, one line. */
159
+ expect([lines[6].cells[0].col, lines[6].cells[0].rows]).toEqual([1, 2])
160
+ expect(lines[7].cells.map(cell => [cell.col, cell.span ?? 1, cell.role])).toEqual([[2, 5, 'value']])
161
+ expect(lines[8].cells[0].rows).toBe(1)
162
+ const three = rosterLayout([KIM, { name: '박지민' }, KIM], WORDS, 'C', TODAY).slice(6)
163
+ expect(three.map(line => [valuesOf(line)[0], line.zebra ?? false])).toEqual([['1', false], ['TypeScript, PostgreSQL', false], ['2', true], ['3', false], ['TypeScript, PostgreSQL', false]])
164
+ /* The roster's own widths: a narrow number; the pages keep theirs. */
165
+ expect(ROSTER_WIDTHS[0]).toBeLessThan(Math.min(...ROSTER_WIDTHS.slice(1)))
166
+ expect(ROSTER_WIDTHS.reduce((sum, one) => sum + one, 0)).toBe(COLUMN_WIDTHS.reduce((sum, one) => sum + one, 0))
119
167
  expect(rosterTaglineOf([], WORDS, TODAY)).toBe('0people')
120
168
  })
121
169
  })
@@ -141,6 +189,15 @@ describe('painted as Excel', () => {
141
189
  it('★ several people open with the roster, then a sheet a person in the order picked - read back from the file', async () => {
142
190
  const book = await readBack([KIM, { name: '박지민' }])
143
191
  expect(book.worksheets.map(sheet => sheet.name)).toEqual(['<rosterTitle>', '김하늘', '박지민'])
192
+ /* The number is merged down the person's two lines, its text at the top. */
193
+ const roster = book.worksheets[0]
194
+ expect(roster.getCell('A7').isMerged && roster.getCell('A8').isMerged).toBe(true)
195
+ expect(roster.getCell('A8').master.address).toBe('A7')
196
+ expect(roster.getCell('A7').value).toBe('1')
197
+ expect(roster.getCell('A7').alignment?.vertical).toBe('top')
198
+ expect(roster.getCell('A9').isMerged).toBe(false)
199
+ /* The roster sheet takes its own widths - a narrow number - and a person's sheet the page's. */
200
+ expect(book.worksheets.map(sheet => sheet.getColumn(1).width)).toEqual([ROSTER_WIDTHS[0], COLUMN_WIDTHS[0], COLUMN_WIDTHS[0]])
144
201
  expect((await readBack([KIM])).worksheets.map(sheet => sheet.name)).toEqual(['김하늘'])
145
202
  })
146
203
 
@@ -177,6 +234,12 @@ describe('painted as HTML for the PDF', () => {
177
234
  it('★ a page a person after the roster, the same cells, spans as colspan, text escaped', () => {
178
235
  const html = profileHtml([KIM, { name: '<b>박</b>' }], WORDS, 'C', TODAY)
179
236
  expect(html.match(/<section class="sheet">/g)).toHaveLength(3)
237
+ /* The roster's number spans the person's two lines; the second line leaves its column out. */
238
+ expect(html).toContain('<td class="value" rowspan="2" style="vertical-align:top">1</td>')
239
+ expect(html).toMatch(/rowspan="2"[^]*?<\/tr><tr[^>]*><td class="value" colspan="5">TypeScript, PostgreSQL<\/td><\/tr>/)
240
+ const firstCol = [...html.matchAll(/<colgroup><col style="width:([\d.]+)%">/g)].map(match => Number(match[1]))
241
+ expect(firstCol[0]).toBeCloseTo((ROSTER_WIDTHS[0] / 104) * 100, 1)
242
+ expect(firstCol[1]).toBeCloseTo((COLUMN_WIDTHS[0] / 104) * 100, 1)
180
243
  expect(html).toContain('<td class="name" colspan="6">김하늘</td>')
181
244
  expect(html).toContain('<span class="tag">TypeScript</span><span class="tag">PostgreSQL</span>')
182
245
  expect(html).toMatch(/<tr class="zebra"|<tr style/)
@@ -102,6 +102,8 @@
102
102
  "label.supervisor-person": "supervisor",
103
103
  "task.organization.people-reading.name": "Reading employees and departments",
104
104
  "task.organization.people-reading.description": "Reads employee and department records. Pay accounts are not among them.",
105
+ "task.organization.departments-reading.name": "Reading departments",
106
+ "task.organization.departments-reading.description": "Reads the department list (name, code, description, parent, extension, picture). The head and the members are not among them.",
105
107
  "task.organization.people-keeping.name": "Keeping employees and departments",
106
108
  "task.organization.people-keeping.description": "Reads and changes employee and department records. Pay accounts are not among them.",
107
109
  "task.organization.pay-accounts.name": "Keeping employees' pay accounts",
@@ -102,6 +102,8 @@
102
102
  "label.supervisor-person": "supervisor",
103
103
  "task.organization.people-reading.name": "Leer empleados y departamentos",
104
104
  "task.organization.people-reading.description": "Lee los registros de empleados y departamentos. Las cuentas de nómina no se incluyen.",
105
+ "task.organization.departments-reading.name": "Leer departamentos",
106
+ "task.organization.departments-reading.description": "Lee la lista de departamentos (nombre, código, descripción, superior, extensión, foto). El responsable y los miembros no se incluyen.",
105
107
  "task.organization.people-keeping.name": "Mantener empleados y departamentos",
106
108
  "task.organization.people-keeping.description": "Lee y modifica los registros de empleados y departamentos. Las cuentas de nómina no se incluyen.",
107
109
  "task.organization.pay-accounts.name": "Cuentas de nómina de los empleados",
@@ -102,6 +102,8 @@
102
102
  "label.supervisor-person": "上司",
103
103
  "task.organization.people-reading.name": "社員・部署の閲覧",
104
104
  "task.organization.people-reading.description": "社員と部署の記録を読む。給与口座は含まない。",
105
+ "task.organization.departments-reading.name": "部署の閲覧",
106
+ "task.organization.departments-reading.description": "部署一覧(名前・コード・説明・上位・内線・写真)を読む。部署長と所属社員は含まない。",
105
107
  "task.organization.people-keeping.name": "社員・部署の管理",
106
108
  "task.organization.people-keeping.description": "社員と部署の記録を読み、変更する。給与口座は含まない。",
107
109
  "task.organization.pay-accounts.name": "社員の給与口座",
@@ -102,6 +102,8 @@
102
102
  "label.supervisor-person": "상사",
103
103
  "task.organization.people-reading.name": "직원·부서 열람",
104
104
  "task.organization.people-reading.description": "직원과 부서 기록을 읽는다. 급여 계좌는 빠진다.",
105
+ "task.organization.departments-reading.name": "부서 열람",
106
+ "task.organization.departments-reading.description": "부서 목록(이름·코드·설명·상위·내선·사진)을 읽는다. 부서장과 소속 직원은 빠진다.",
105
107
  "task.organization.people-keeping.name": "직원·부서 관리",
106
108
  "task.organization.people-keeping.description": "직원과 부서 기록을 읽고 바꾼다. 급여 계좌는 빠진다.",
107
109
  "task.organization.pay-accounts.name": "직원 급여 계좌",
@@ -102,6 +102,8 @@
102
102
  "label.supervisor-person": "penyelia",
103
103
  "task.organization.people-reading.name": "Membaca pekerja dan jabatan",
104
104
  "task.organization.people-reading.description": "Membaca rekod pekerja dan jabatan. Akaun gaji tidak termasuk.",
105
+ "task.organization.departments-reading.name": "Membaca jabatan",
106
+ "task.organization.departments-reading.description": "Membaca senarai jabatan (nama, kod, keterangan, induk, sambungan, gambar). Ketua dan ahli jabatan tidak termasuk.",
105
107
  "task.organization.people-keeping.name": "Menyelenggara pekerja dan jabatan",
106
108
  "task.organization.people-keeping.description": "Membaca dan mengubah rekod pekerja dan jabatan. Akaun gaji tidak termasuk.",
107
109
  "task.organization.pay-accounts.name": "Akaun gaji pekerja",
@@ -102,6 +102,8 @@
102
102
  "label.supervisor-person": "上级",
103
103
  "task.organization.people-reading.name": "查看员工与部门",
104
104
  "task.organization.people-reading.description": "读取员工和部门记录。不含工资账户。",
105
+ "task.organization.departments-reading.name": "查看部门",
106
+ "task.organization.departments-reading.description": "读取部门列表(名称、代码、说明、上级、分机、照片)。不含部门负责人和所属员工。",
105
107
  "task.organization.people-keeping.name": "管理员工与部门",
106
108
  "task.organization.people-keeping.description": "读取并修改员工和部门记录。不含工资账户。",
107
109
  "task.organization.pay-accounts.name": "员工工资账户",