@things-factory/ai-dashboard 10.1.221 → 10.1.222
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.
- package/client/components/card-dialogs.ts +1 -1
- package/client/utils/card-api.ts +1 -2
- package/dist-client/components/card-dialogs.js +1 -1
- package/dist-client/components/card-dialogs.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/card-api.d.ts +0 -1
- package/dist-client/utils/card-api.js +1 -1
- package/dist-client/utils/card-api.js.map +1 -1
- package/dist-server/service/card/card-access.d.ts +4 -33
- package/dist-server/service/card/card-access.js +6 -35
- package/dist-server/service/card/card-access.js.map +1 -1
- package/dist-server/service/card/card-resolver.js +11 -34
- package/dist-server/service/card/card-resolver.js.map +1 -1
- package/dist-server/service/card/card-run.d.ts +2 -0
- package/dist-server/service/card/card-run.js +7 -4
- package/dist-server/service/card/card-run.js.map +1 -1
- package/dist-server/service/card/card-tool.js +8 -12
- package/dist-server/service/card/card-tool.js.map +1 -1
- package/dist-server/service/card/card-types.d.ts +0 -1
- package/dist-server/service/card/card-types.js +0 -4
- package/dist-server/service/card/card-types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/server/service/card/card-access.test.ts +5 -25
- package/server/service/card/card-access.ts +7 -43
- package/server/service/card/card-resolver.ts +15 -36
- package/server/service/card/card-run.ts +10 -5
- package/server/service/card/card-tool.ts +8 -11
- package/server/service/card/card-types.ts +0 -3
- package/server/service/card/card-words.test.ts +72 -0
- package/test/translations.test.ts +1 -1
- package/translations/en.json +2 -2
- package/translations/es.json +2 -2
- package/translations/ja.json +2 -2
- package/translations/ko.json +2 -2
- package/translations/ms.json +2 -2
- package/translations/zh.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-dashboard",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.222",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -33,10 +33,11 @@
|
|
|
33
33
|
"@operato/popup": "^10.29.39",
|
|
34
34
|
"@operato/shell": "^10.29.39",
|
|
35
35
|
"@operato/styles": "^10.0.0",
|
|
36
|
-
"@things-factory/ai-assistant": "^10.1.
|
|
36
|
+
"@things-factory/ai-assistant": "^10.1.222",
|
|
37
37
|
"@things-factory/ai-client-base": "^10.1.218",
|
|
38
38
|
"@things-factory/auth-base": "^10.1.218",
|
|
39
39
|
"@things-factory/env": "^10.1.149",
|
|
40
|
+
"@things-factory/organization": "^10.1.222",
|
|
40
41
|
"@things-factory/shell": "^10.1.208",
|
|
41
42
|
"graphql-scalars": "^1.22.0",
|
|
42
43
|
"graphql-tag": "^2.12.6",
|
|
@@ -45,5 +46,5 @@
|
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"copyfiles": "^2.4.1"
|
|
47
48
|
},
|
|
48
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "afdd6a9b1c8b440e39d4fec48bfbad1a0c02fccc"
|
|
49
50
|
}
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { listReadTarget, type DetailRead } from '@things-factory/ai-assistant'
|
|
5
5
|
|
|
6
|
-
import { cardAccessOf
|
|
6
|
+
import { cardAccessOf } from './card-access'
|
|
7
7
|
import { findCardProposal } from './card-proposal'
|
|
8
8
|
import { proposeDashboardCard, readDashboardCard } from './card-tool'
|
|
9
9
|
import { CARD_AXIS } from './card-names'
|
|
10
10
|
import { CARD_AXIS as CLIENT_AXIS, cardSelection } from '../../../client/utils/card-words'
|
|
11
11
|
|
|
12
|
-
const viewer = { userId: 'u-me', domainId: 'd1', departmentIds: ['dep-sales'] }
|
|
12
|
+
const viewer = { userId: 'u-me', domainId: 'd1', departmentIds: ['dep-sales'], roleIds: [] as string[] }
|
|
13
13
|
|
|
14
14
|
describe('who may use a card', () => {
|
|
15
15
|
it('★ its owner, and whoever a share names - a person, a department one is active in, the domain', () => {
|
|
@@ -27,9 +27,6 @@ describe('who may use a card', () => {
|
|
|
27
27
|
expect(cardAccessOf({ ownerId: 'u-x', domainId: 'd1' }, holder, [{ scope: 'ROLE', targetId: 'role-hr' }])).toBe('shared')
|
|
28
28
|
expect(cardAccessOf({ ownerId: 'u-x', domainId: 'd1' }, holder, [{ scope: 'ROLE', targetId: 'role-finance' }])).toBeUndefined()
|
|
29
29
|
expect(cardAccessOf({ ownerId: 'u-x', domainId: 'd1' }, viewer, [{ scope: 'ROLE', targetId: 'role-hr' }])).toBeUndefined()
|
|
30
|
-
/* A role of the domain is given; one it does not have is refused. */
|
|
31
|
-
expect(shareRefusal({ scope: 'ROLE', targetId: 'role-hr' }, { domainId: 'd1', departmentsAvailable: true, roleIds: ['role-hr'] })).toBeUndefined()
|
|
32
|
-
expect(shareRefusal({ scope: 'ROLE', targetId: 'role-other' }, { domainId: 'd1', departmentsAvailable: true, roleIds: ['role-hr'] })).toBe('unknown-role')
|
|
33
30
|
})
|
|
34
31
|
|
|
35
32
|
it('★ never across domains - not even its owner in another domain', () => {
|
|
@@ -38,26 +35,9 @@ describe('who may use a card', () => {
|
|
|
38
35
|
expect(cardAccessOf({ ownerId: 'u-me', domainId: 'd1' }, { ...viewer, userId: '' }, [])).toBeUndefined()
|
|
39
36
|
})
|
|
40
37
|
|
|
41
|
-
it('★ a department
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
{ active: true, department: { id: 'dep-sales' } },
|
|
45
|
-
{ active: false, department: { id: 'dep-old' } },
|
|
46
|
-
{ active: null, department: { id: 'dep-unset' } },
|
|
47
|
-
{ active: true, department: null },
|
|
48
|
-
{ active: true, department: { id: 'dep-sales' } }
|
|
49
|
-
])
|
|
50
|
-
).toEqual(['dep-sales'])
|
|
51
|
-
expect(departmentsOfEmployees(undefined)).toEqual([])
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('a share the owner may give: a known scope, a target, their own domain, departments only where they are known', () => {
|
|
55
|
-
const owner = { domainId: 'd1', departmentsAvailable: true }
|
|
56
|
-
expect(shareRefusal({ scope: 'USER', targetId: 'u-x' }, owner)).toBeUndefined()
|
|
57
|
-
expect(shareRefusal({ scope: 'TEAM', targetId: 'x' }, owner)).toBe('unknown-scope')
|
|
58
|
-
expect(shareRefusal({ scope: 'USER', targetId: '' }, owner)).toBe('no-target')
|
|
59
|
-
expect(shareRefusal({ scope: 'DOMAIN', targetId: 'd2' }, owner)).toBe('another-domain')
|
|
60
|
-
expect(shareRefusal({ scope: 'DEPARTMENT', targetId: 'dep' }, { ...owner, departmentsAvailable: false })).toBe('no-departments')
|
|
38
|
+
it('★ a department share reaches the people of the departments under it - the viewer stands in those above (organization audience)', () => {
|
|
39
|
+
const inTeam = { ...viewer, departmentIds: ['dep-sales-1', 'dep-sales'] }
|
|
40
|
+
expect(cardAccessOf({ ownerId: 'u-x', domainId: 'd1' }, inTeam, [{ scope: 'DEPARTMENT', targetId: 'dep-sales' }])).toBe('shared')
|
|
61
41
|
})
|
|
62
42
|
})
|
|
63
43
|
|
|
@@ -4,57 +4,21 @@
|
|
|
4
4
|
* definition, never the data behind it. Asked again on every read, run, placing and copy - a share withdrawn is gone
|
|
5
5
|
* at once, whatever is still placed.
|
|
6
6
|
*
|
|
7
|
+
* Whom a share reaches is the audience rule of organization (ADR-0163 decision 2) - the one an announcement asks too.
|
|
8
|
+
*
|
|
7
9
|
* Pure - the rule is tested without a database.
|
|
8
10
|
*/
|
|
11
|
+
import { audienceReaches, type AudienceMember, type AudienceTarget } from '@things-factory/organization'
|
|
9
12
|
|
|
10
13
|
export type CardAccess = 'owner' | 'shared'
|
|
11
14
|
|
|
12
|
-
export
|
|
13
|
-
scope: 'USER' | 'DEPARTMENT' | 'ROLE' | 'DOMAIN' | string
|
|
14
|
-
targetId: string
|
|
15
|
-
}
|
|
15
|
+
export type CardShareRow = AudienceTarget
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
domainId: string
|
|
20
|
-
/** The departments of the viewer's active employee records - none where the organization module is not there. */
|
|
21
|
-
departmentIds: readonly string[]
|
|
22
|
-
/** The roles the viewer holds in the domain, the ones it inherits included - a role share reaches whoever holds it. */
|
|
23
|
-
roleIds?: readonly string[]
|
|
24
|
-
}
|
|
17
|
+
/** The viewer as an audience sees them - the departments they stand in (those above included) and the roles they hold. */
|
|
18
|
+
export type CardViewer = AudienceMember
|
|
25
19
|
|
|
26
20
|
export function cardAccessOf(card: { ownerId?: string | null; domainId?: string | null }, viewer: CardViewer, shares: readonly CardShareRow[]): CardAccess | undefined {
|
|
27
21
|
if (!viewer.userId || !viewer.domainId || card.domainId !== viewer.domainId) return undefined
|
|
28
22
|
if (card.ownerId === viewer.userId) return 'owner'
|
|
29
|
-
|
|
30
|
-
share =>
|
|
31
|
-
(share.scope === 'USER' && share.targetId === viewer.userId) ||
|
|
32
|
-
(share.scope === 'DOMAIN' && share.targetId === viewer.domainId) ||
|
|
33
|
-
(share.scope === 'DEPARTMENT' && viewer.departmentIds.includes(share.targetId)) ||
|
|
34
|
-
(share.scope === 'ROLE' && (viewer.roleIds ?? []).includes(share.targetId))
|
|
35
|
-
)
|
|
36
|
-
return shared ? 'shared' : undefined
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The departments a person belongs to for sharing - their employee records that are active (`active = true`; an
|
|
41
|
-
* unset `active` is not taken, as the assistant's people read takes only `active eq true`), each record's department.
|
|
42
|
-
* A person with several records belongs to each record's department. Nothing is guessed: a record without a
|
|
43
|
-
* department adds none.
|
|
44
|
-
*/
|
|
45
|
-
export function departmentsOfEmployees(records: readonly { active?: boolean | null; department?: { id?: string | null } | null }[] | null | undefined): string[] {
|
|
46
|
-
return [...new Set((records ?? []).filter(one => one?.active === true && one.department?.id).map(one => one.department!.id!))]
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* A share the owner may give - a known scope with a target; the domain scope names the owner's own domain, a role one
|
|
51
|
-
* of the domain's roles (`roleIds`, the ones it inherits included).
|
|
52
|
-
*/
|
|
53
|
-
export function shareRefusal(share: CardShareRow, owner: { domainId: string; departmentsAvailable: boolean; roleIds?: readonly string[] }): string | undefined {
|
|
54
|
-
if (!['USER', 'DEPARTMENT', 'ROLE', 'DOMAIN'].includes(share.scope)) return 'unknown-scope'
|
|
55
|
-
if (!share.targetId) return 'no-target'
|
|
56
|
-
if (share.scope === 'DOMAIN' && share.targetId !== owner.domainId) return 'another-domain'
|
|
57
|
-
if (share.scope === 'DEPARTMENT' && !owner.departmentsAvailable) return 'no-departments'
|
|
58
|
-
if (share.scope === 'ROLE' && !(owner.roleIds ?? []).includes(share.targetId)) return 'unknown-role'
|
|
59
|
-
return undefined
|
|
23
|
+
return audienceReaches(shares, viewer) ? 'shared' : undefined
|
|
60
24
|
}
|
|
@@ -10,8 +10,9 @@ import { Arg, Ctx, Directive, Int, Mutation, Query, Resolver } from 'type-graphq
|
|
|
10
10
|
import { GraphQLJSON } from 'graphql-scalars'
|
|
11
11
|
import { In, IsNull, Not } from 'typeorm'
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { getRepository } from '@things-factory/shell'
|
|
14
|
+
import { User } from '@things-factory/auth-base'
|
|
15
|
+
import { audienceMemberOf, audienceTargetRefusal, departmentTreeOf, domainRolesOf } from '@things-factory/organization'
|
|
15
16
|
import {
|
|
16
17
|
assertMayWriteSession,
|
|
17
18
|
ChatMessage,
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
toolAccessFor
|
|
26
27
|
} from '@things-factory/ai-assistant'
|
|
27
28
|
|
|
28
|
-
import { cardAccessOf,
|
|
29
|
+
import { cardAccessOf, type CardAccess, type CardViewer } from './card-access.js'
|
|
29
30
|
import { CARD_SCHEMA_VERSION, checkDefinition, sourceOf, todayIn, type CardDefinition } from './card-definition.js'
|
|
30
31
|
import { cardIdOf, findCardProposal } from './card-proposal.js'
|
|
31
32
|
import { runCard, type CardRunResult } from './card-run.js'
|
|
@@ -46,37 +47,15 @@ function refuse(context: ResolverContext, code: string, fallback: string, option
|
|
|
46
47
|
throw error
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
/** The viewer of a request, with the departments they belong to for sharing - asked once per request. */
|
|
50
50
|
/**
|
|
51
|
-
* The
|
|
52
|
-
*
|
|
51
|
+
* The viewer of a request as an audience sees them (organization, ADR-0163 decision 2), and the departments of their own
|
|
52
|
+
* active records - the ones they may share with. Asked once per request.
|
|
53
53
|
*/
|
|
54
|
-
async function
|
|
55
|
-
const qb = getRepository(Role, tx).createQueryBuilder('ROLE')
|
|
56
|
-
if (holder) qb.innerJoin('ROLE.users', 'HOLDER', 'HOLDER.id = :holder', { holder })
|
|
57
|
-
applyDomainInheritanceFilter(qb, 'ROLE', context.state.domain as any, InheritedValueType.Include)
|
|
58
|
-
return await qb.orderBy('ROLE.name').getMany()
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function viewerOf(context: ResolverContext): Promise<{ viewer: CardViewer; departmentsAvailable: boolean; departments: { id: string; name: string }[] }> {
|
|
54
|
+
async function viewerOf(context: ResolverContext): Promise<{ viewer: CardViewer; departments: { id: string; name: string }[] }> {
|
|
62
55
|
const state: any = context.state
|
|
63
56
|
if (state.__aiDashboardViewer) return state.__aiDashboardViewer
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/* The organization module's own door, as the viewer - absent in an application without it. */
|
|
67
|
-
const answer = await runAsCaller(context)('query { myEmployees { active department { id name } } }', {})
|
|
68
|
-
if (!answer.errors?.length) records = answer.data?.myEmployees ?? []
|
|
69
|
-
} catch {
|
|
70
|
-
records = undefined
|
|
71
|
-
}
|
|
72
|
-
const departmentIds = departmentsOfEmployees(records)
|
|
73
|
-
const departments = departmentIds.map(id => ({ id, name: records!.find(one => one?.department?.id === id)?.department?.name ?? id }))
|
|
74
|
-
const roleIds = state.user?.id ? (await domainRoles(context, state.user.id)).map(one => one.id) : []
|
|
75
|
-
const answer = {
|
|
76
|
-
viewer: { userId: state.user?.id, domainId: state.domain?.id, departmentIds, roleIds },
|
|
77
|
-
departmentsAvailable: records !== undefined,
|
|
78
|
-
departments
|
|
79
|
-
}
|
|
57
|
+
const { ownDepartments, ...viewer } = await audienceMemberOf(state.domain, state.user ?? {})
|
|
58
|
+
const answer = { viewer, departments: ownDepartments }
|
|
80
59
|
state.__aiDashboardViewer = answer
|
|
81
60
|
return answer
|
|
82
61
|
}
|
|
@@ -148,6 +127,7 @@ export async function runAsViewer(definition: CardDefinition, context: ResolverC
|
|
|
148
127
|
passesDoor: door => toolAccessFor(context)(door),
|
|
149
128
|
run: runAsCaller(context),
|
|
150
129
|
today: todayIn(context.state.domain?.timezone),
|
|
130
|
+
timeZone: context.state.domain?.timezone,
|
|
151
131
|
words: (key, fallback) => {
|
|
152
132
|
if (!key || typeof context.t !== 'function') return fallback
|
|
153
133
|
const said = context.t(key)
|
|
@@ -226,15 +206,14 @@ export class DashboardCardResolver {
|
|
|
226
206
|
@Directive(QUERY)
|
|
227
207
|
@Query(returns => DashboardShareOptions, { description: 'What the viewer may share a card with - the domain, their own departments, and whether people can be looked up.' })
|
|
228
208
|
async dashboardShareOptions(@Ctx() context: ResolverContext): Promise<DashboardShareOptions> {
|
|
229
|
-
const {
|
|
209
|
+
const { departments } = await viewerOf(context)
|
|
230
210
|
const domain: any = context.state.domain
|
|
231
211
|
return {
|
|
232
212
|
domainId: domain.id,
|
|
233
213
|
domainName: domain.name ?? domain.subdomain ?? domain.id,
|
|
234
|
-
departmentsAvailable,
|
|
235
214
|
departments,
|
|
236
215
|
peopleAvailable: await toolAccessFor(context)('Query.employees'),
|
|
237
|
-
roles: (await
|
|
216
|
+
roles: (await domainRolesOf(context.state.domain)).map(one => ({ id: one.id, name: one.name, ...(one.description ? { note: one.description } : {}) }))
|
|
238
217
|
}
|
|
239
218
|
}
|
|
240
219
|
|
|
@@ -381,11 +360,11 @@ export class DashboardCardResolver {
|
|
|
381
360
|
async shareDashboardCard(@Arg('id') id: string, @Arg('shares', type => [DashboardCardShareInput]) shares: DashboardCardShareInput[], @Ctx() context: ResolverContext): Promise<DashboardCardItem> {
|
|
382
361
|
const { domain, user, tx } = context.state as any
|
|
383
362
|
const card = await ownCard(id, context, tx)
|
|
384
|
-
const
|
|
385
|
-
const
|
|
363
|
+
const roleIds = (shares ?? []).some(one => one.scope === DashboardCardShareScope.ROLE) ? (await domainRolesOf(domain, undefined, tx)).map(one => one.id) : []
|
|
364
|
+
const departmentIds = (shares ?? []).some(one => one.scope === DashboardCardShareScope.DEPARTMENT) ? [...(await departmentTreeOf(domain, tx)).keys()] : []
|
|
386
365
|
const wanted = new Map<string, DashboardCardShareInput>()
|
|
387
366
|
for (const share of shares ?? []) {
|
|
388
|
-
const refusal =
|
|
367
|
+
const refusal = audienceTargetRefusal(share, { domainId: domain.id, departmentIds, roleIds })
|
|
389
368
|
if (refusal) refuse(context, `share-${refusal}`, 'this share cannot be given ({code}).', { code: refusal })
|
|
390
369
|
if (share.scope === DashboardCardShareScope.USER && share.targetId === user.id) continue
|
|
391
370
|
wanted.set(`${share.scope}:${share.targetId}`, share)
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Dependencies come in as arguments - tested without a server.
|
|
10
10
|
*/
|
|
11
|
-
import { filtersOf, MAX_READ_ROWS, readCriteria, readRows, rootFieldOf, rowOf, runDetail, valueAt, type ReadColumn } from '@things-factory/ai-assistant'
|
|
11
|
+
import { filtersOf, MAX_READ_ROWS, readCriteria, readRows, rootFieldOf, rowOf, runDetail, valueAt, wordOf, type ReadColumn } from '@things-factory/ai-assistant'
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
checkDefinition,
|
|
@@ -38,6 +38,8 @@ export interface CardRunDeps {
|
|
|
38
38
|
run: Runner
|
|
39
39
|
/** Today in the domain's time zone - `YYYY-MM-DD`. */
|
|
40
40
|
today: string
|
|
41
|
+
/** The domain's time zone - a moment in the rows is read in it. */
|
|
42
|
+
timeZone?: string
|
|
41
43
|
/** The reader's words for a label key. */
|
|
42
44
|
words?(key: string | undefined, fallback: string): string
|
|
43
45
|
}
|
|
@@ -106,7 +108,9 @@ async function readSource(source: CardSource, where: Record<string, unknown>, de
|
|
|
106
108
|
const { criteria, refused } = readCriteria(source.target!, where)
|
|
107
109
|
if (refused.length) return { issue: { code: 'condition-unreadable', params: { refused } } }
|
|
108
110
|
try {
|
|
109
|
-
const read = await readRows(source.target!, filtersOf(source.target!, criteria), deps.run, typeof criteria.text === 'string' && source.target!.search ? criteria.text : undefined
|
|
111
|
+
const read = await readRows(source.target!, filtersOf(source.target!, criteria), deps.run, typeof criteria.text === 'string' && source.target!.search ? criteria.text : undefined, {
|
|
112
|
+
timeZone: deps.timeZone
|
|
113
|
+
})
|
|
110
114
|
return { rows: read.rows as Row[], total: read.total, complete: read.complete }
|
|
111
115
|
} catch (error: any) {
|
|
112
116
|
return { issue: { code: 'query-failed', params: { message: String(error?.message ?? error) } } }
|
|
@@ -120,7 +124,7 @@ async function readSource(source: CardSource, where: Record<string, unknown>, de
|
|
|
120
124
|
const items = at ? valueAt(ran.value, at) : ran.value
|
|
121
125
|
/* An empty list is no rows; a path that holds no list is the declaration's mistake, said as such. */
|
|
122
126
|
if (!Array.isArray(items)) return { issue: { code: 'list-missing', params: { read: source.key, list: source.list!.key, at } } }
|
|
123
|
-
const rows = items.slice(0, MAX_READ_ROWS).map(item => rowOf({ columns: source.columns }, item) as Row)
|
|
127
|
+
const rows = items.slice(0, MAX_READ_ROWS).map(item => rowOf({ columns: source.columns }, item, { timeZone: deps.timeZone }) as Row)
|
|
124
128
|
return { rows, total: items.length, complete: items.length <= MAX_READ_ROWS }
|
|
125
129
|
}
|
|
126
130
|
|
|
@@ -267,7 +271,8 @@ export async function runCard(definition: CardDefinition, deps: CardRunDeps): Pr
|
|
|
267
271
|
block: {
|
|
268
272
|
kind: 'table',
|
|
269
273
|
columns: drawn.map(column => ({ name: column.name, label: label(column), type: column.type })),
|
|
270
|
-
|
|
274
|
+
/* A coded value is shown by its word in the viewer's language; the rows were matched and sorted by the code. */
|
|
275
|
+
rows: rows.map(row => Object.fromEntries(drawn.map(column => [column.name, wordOf(column, row[column.name] ?? null, words)])))
|
|
271
276
|
},
|
|
272
277
|
counts,
|
|
273
278
|
asOf,
|
|
@@ -283,7 +288,7 @@ export async function runCard(definition: CardDefinition, deps: CardRunDeps): Pr
|
|
|
283
288
|
block: {
|
|
284
289
|
kind: 'chart',
|
|
285
290
|
chart: view.kind,
|
|
286
|
-
x: rows.map(row => String(row[x] ?? '').slice(0, 60)),
|
|
291
|
+
x: rows.map(row => String(wordOf(shaped.columns.find(one => one.name === x), row[x] ?? '', words) ?? '').slice(0, 60)),
|
|
287
292
|
series: series.map(column => ({ name: label(column), values: rows.map(row => (typeof row[column.name] === 'number' ? (row[column.name] as number) : null)) })),
|
|
288
293
|
...(unitOf(series) && unitOf(series) !== 'unknown' ? { unit: unitOf(series) } : {})
|
|
289
294
|
},
|
|
@@ -156,7 +156,8 @@ export async function proposeDashboardCard(args: any, context: any, deps: { run?
|
|
|
156
156
|
detailReadOf,
|
|
157
157
|
passesDoor: deps.passesDoor ?? (door => toolAccessFor(context)(door)),
|
|
158
158
|
run: deps.run ?? runAsCaller(context),
|
|
159
|
-
today: deps.today ?? todayIn(context?.state?.domain?.timezone)
|
|
159
|
+
today: deps.today ?? todayIn(context?.state?.domain?.timezone),
|
|
160
|
+
timeZone: context?.state?.domain?.timezone
|
|
160
161
|
})
|
|
161
162
|
if (ran.issue) {
|
|
162
163
|
return { rejected: true, error: ran.issue.code, ...(ran.issue.params ? { params: ran.issue.params } : {}), source: sourceSheet(source, definition.rows?.groupBy), message: 'the card could not be drawn - fix the definition and call again in this turn; say plainly if it cannot be.' }
|
|
@@ -314,23 +315,19 @@ export async function readDashboardCard(args: any, context: any, cardOf?: (id: s
|
|
|
314
315
|
const usableInDomain =
|
|
315
316
|
(context: any) =>
|
|
316
317
|
async (id: string): Promise<{ card?: any; access?: string }> => {
|
|
317
|
-
const [{ getRepository }, { DashboardCard, DashboardCardShare }, { cardAccessOf,
|
|
318
|
+
const [{ getRepository }, { DashboardCard, DashboardCardShare }, { cardAccessOf }, { audienceMemberOf }] = await Promise.all([
|
|
318
319
|
import('@things-factory/shell'),
|
|
319
320
|
import('./dashboard-card.js'),
|
|
320
|
-
import('./card-access.js')
|
|
321
|
+
import('./card-access.js'),
|
|
322
|
+
import('@things-factory/organization')
|
|
321
323
|
])
|
|
322
324
|
const domainId = context?.state?.domain?.id
|
|
323
325
|
const card = await getRepository(DashboardCard).findOne({ where: { id, domain: { id: domainId } } as any })
|
|
324
326
|
if (!card) return {}
|
|
325
327
|
const shares = await getRepository(DashboardCardShare).find({ where: { card: { id: card.id } } as any })
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
records = answer.errors?.length ? [] : answer.data?.myEmployees
|
|
330
|
-
} catch {
|
|
331
|
-
records = []
|
|
332
|
-
}
|
|
333
|
-
const access = cardAccessOf(card, { userId: context?.state?.user?.id, domainId, departmentIds: departmentsOfEmployees(records) }, shares)
|
|
328
|
+
/* The same viewer the card doors see - its roles included, which this read left out until 2026-10-26. */
|
|
329
|
+
const { ownDepartments, ...viewer } = await audienceMemberOf(context?.state?.domain, context?.state?.user ?? {})
|
|
330
|
+
const access = cardAccessOf(card, viewer, shares)
|
|
334
331
|
return { card, access }
|
|
335
332
|
}
|
|
336
333
|
|
|
@@ -107,9 +107,6 @@ export class DashboardShareOptions {
|
|
|
107
107
|
@Field({ description: 'The domain name.' })
|
|
108
108
|
domainName: string
|
|
109
109
|
|
|
110
|
-
@Field({ description: 'Whether departments are known here (the organization module).' })
|
|
111
|
-
departmentsAvailable: boolean
|
|
112
|
-
|
|
113
110
|
@Field(type => [DashboardShareChoice], { description: "The viewer's own departments." })
|
|
114
111
|
departments: DashboardShareChoice[]
|
|
115
112
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A card shows people words and moments they read (ADR-0163, 2026-10-26: a card of announcements showed EVENT, unread
|
|
3
|
+
* and 2026-10-26T09:09:03.235Z) - matched and sorted by the code, drawn by the word in the viewer's language.
|
|
4
|
+
*/
|
|
5
|
+
import { listReadTarget } from '@things-factory/ai-assistant'
|
|
6
|
+
|
|
7
|
+
import { runCard, type CardRunDeps } from './card-run'
|
|
8
|
+
|
|
9
|
+
const notices = listReadTarget({
|
|
10
|
+
key: 'test.notices',
|
|
11
|
+
title: 'title.notices',
|
|
12
|
+
name: 'notices',
|
|
13
|
+
listQuery: 'notices',
|
|
14
|
+
selection: 'id title kind state publishedAt',
|
|
15
|
+
columns: [
|
|
16
|
+
{ name: 'title', type: 'string' },
|
|
17
|
+
{ name: 'kind', type: 'string', words: 'notice.kind.' },
|
|
18
|
+
{ name: 'state', type: 'string', words: 'notice.state.' },
|
|
19
|
+
{ name: 'publishedAt', type: 'date' }
|
|
20
|
+
]
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const items = [
|
|
24
|
+
{ id: 'n1', title: '워크숍', kind: 'EVENT', state: 'unread', publishedAt: '2026-10-26T09:09:03.235Z' },
|
|
25
|
+
{ id: 'n2', title: '휴무', kind: 'HR', state: 'read', publishedAt: '2026-10-25T20:00:00Z' },
|
|
26
|
+
{ id: 'n3', title: '점검', kind: 'EVENT', state: 'unread', publishedAt: '2026-10-24T01:00:00Z' }
|
|
27
|
+
]
|
|
28
|
+
const WORDS: Record<string, string> = { 'notice.kind.HR': '인사', 'notice.kind.EVENT': '행사', 'notice.state.unread': '안 읽음', 'notice.state.read': '읽음' }
|
|
29
|
+
|
|
30
|
+
const deps = (over: Partial<CardRunDeps> = {}): CardRunDeps => ({
|
|
31
|
+
readTargetOf: key => (key === notices.key ? notices : undefined),
|
|
32
|
+
detailReadOf: () => undefined,
|
|
33
|
+
passesDoor: async () => true,
|
|
34
|
+
run: async () => ({ data: { notices: { items, total: items.length } } }),
|
|
35
|
+
today: '2026-10-26',
|
|
36
|
+
/* Not this machine's zone - a run that forgot the domain's zone would read the machine's instead. */
|
|
37
|
+
timeZone: 'UTC',
|
|
38
|
+
words: (key, fallback) => (key && WORDS[key]) || fallback,
|
|
39
|
+
...over
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('★ a table matched by the code shows the words and the moments in the domain\'s time', async () => {
|
|
43
|
+
const ran: any = await runCard(
|
|
44
|
+
{ schemaVersion: 1, source: { read: notices.key }, rows: { match: [{ column: 'state', op: 'eq', value: 'unread' }] }, view: { kind: 'table', columns: ['title', 'kind', 'state', 'publishedAt'] } },
|
|
45
|
+
deps()
|
|
46
|
+
)
|
|
47
|
+
expect(ran.issue).toBeUndefined()
|
|
48
|
+
expect(ran.block.rows).toEqual([
|
|
49
|
+
{ title: '워크숍', kind: '행사', state: '안 읽음', publishedAt: '2026-10-26 09:09' },
|
|
50
|
+
{ title: '점검', kind: '행사', state: '안 읽음', publishedAt: '2026-10-24 01:00' }
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('★ a chart grouped by a coded column names its bars by the words', async () => {
|
|
55
|
+
const ran: any = await runCard({ schemaVersion: 1, source: { read: notices.key }, rows: { groupBy: 'kind' }, view: { kind: 'bar', values: ['count'] } }, deps())
|
|
56
|
+
expect(ran.issue).toBeUndefined()
|
|
57
|
+
expect([...ran.block.x].sort()).toEqual(['인사', '행사'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('a viewer without the words sees the code - never nothing', async () => {
|
|
61
|
+
const ran: any = await runCard({ schemaVersion: 1, source: { read: notices.key }, view: { kind: 'table', columns: ['kind'] } }, deps({ words: undefined }))
|
|
62
|
+
expect(ran.block.rows.map((one: any) => one.kind)).toEqual(['EVENT', 'HR', 'EVENT'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('★ a moment is grouped by the month of the domain\'s time - 31 October 20:00 UTC is November in Seoul', async () => {
|
|
66
|
+
const late = [{ id: 'n9', title: '월말', kind: 'HR', state: 'read', publishedAt: '2026-10-31T20:00:00Z' }]
|
|
67
|
+
const ran: any = await runCard(
|
|
68
|
+
{ schemaVersion: 1, source: { read: notices.key }, rows: { groupBy: 'publishedAt' }, view: { kind: 'bar', values: ['count'] } },
|
|
69
|
+
deps({ timeZone: 'Asia/Seoul', run: async () => ({ data: { notices: { items: late, total: 1 } } }) })
|
|
70
|
+
)
|
|
71
|
+
expect(ran.block.x).toEqual(['2026-11'])
|
|
72
|
+
})
|
|
@@ -18,7 +18,7 @@ const locale = (lang: string): Record<string, string> => JSON.parse(readFileSync
|
|
|
18
18
|
const used = new Set<string>([...text.matchAll(/'(ai-dashboard\.[a-z0-9.-]+[a-z0-9])'/g)].map(match => match[1]).filter(key => key !== 'ai-dashboard.use'))
|
|
19
19
|
/* Keys built from a code: the refusals (refuse(context, '<code>', ...)), the shares' (share-<code>), the issues a card names, the views. */
|
|
20
20
|
for (const [, code] of text.matchAll(/refuse\(context, '([a-z-]+)'/g)) used.add(`ai-dashboard.refusal.${code}`)
|
|
21
|
-
for (const code of ['unknown-scope', 'no-target', 'another-domain', '
|
|
21
|
+
for (const code of ['unknown-scope', 'no-target', 'another-domain', 'unknown-department', 'unknown-role']) used.add(`ai-dashboard.refusal.share-${code}`)
|
|
22
22
|
const issues = /KNOWN_ISSUES = \[([\s\S]*?)\]/.exec(text)![1]
|
|
23
23
|
for (const [, code] of issues.matchAll(/'([a-z-]+)'/g)) used.add(`ai-dashboard.issue.${code}`)
|
|
24
24
|
for (const view of ['number', 'table', 'bar', 'line']) used.add(`ai-dashboard.view.${view}`)
|
package/translations/en.json
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"ai-dashboard.share.everyone-in": "Everyone in {name}",
|
|
50
50
|
"ai-dashboard.share.everyone": "everyone",
|
|
51
51
|
"ai-dashboard.share.departments": "My departments",
|
|
52
|
-
"ai-dashboard.share.department-members": "active members",
|
|
52
|
+
"ai-dashboard.share.department-members": "active members, sub-departments included",
|
|
53
53
|
"ai-dashboard.share.roles": "Roles",
|
|
54
54
|
"ai-dashboard.share.role-holders": "whoever holds this role",
|
|
55
55
|
"ai-dashboard.share.people": "People",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"ai-dashboard.refusal.share-unknown-scope": "An unknown share scope.",
|
|
117
117
|
"ai-dashboard.refusal.share-no-target": "The share names no one.",
|
|
118
118
|
"ai-dashboard.refusal.share-another-domain": "A card cannot be shared with another site.",
|
|
119
|
-
"ai-dashboard.refusal.share-
|
|
119
|
+
"ai-dashboard.refusal.share-unknown-department": "A card can be shared only with a department of this domain.",
|
|
120
120
|
"ai-dashboard.refusal.share-unknown-role": "A card can be shared only with a role of this domain.",
|
|
121
121
|
"ai-dashboard.refusal.unknown-place": "That card is not on your dashboard.",
|
|
122
122
|
"ai-dashboard.refusal.size-out-of-range": "A card spans 1 to 4 columns and 1 to 2 rows.",
|
package/translations/es.json
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"ai-dashboard.share.everyone-in": "Todos en {name}",
|
|
50
50
|
"ai-dashboard.share.everyone": "todos",
|
|
51
51
|
"ai-dashboard.share.departments": "Mis departamentos",
|
|
52
|
-
"ai-dashboard.share.department-members": "miembros activos",
|
|
52
|
+
"ai-dashboard.share.department-members": "miembros activos, subdepartamentos incluidos",
|
|
53
53
|
"ai-dashboard.share.roles": "Roles",
|
|
54
54
|
"ai-dashboard.share.role-holders": "quien tenga este rol",
|
|
55
55
|
"ai-dashboard.share.people": "Personas",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"ai-dashboard.refusal.share-unknown-scope": "Un ámbito de uso compartido desconocido.",
|
|
117
117
|
"ai-dashboard.refusal.share-no-target": "El uso compartido no nombra a nadie.",
|
|
118
118
|
"ai-dashboard.refusal.share-another-domain": "No se puede compartir una tarjeta con otro sitio.",
|
|
119
|
-
"ai-dashboard.refusal.share-
|
|
119
|
+
"ai-dashboard.refusal.share-unknown-department": "Solo se puede compartir con un departamento de este dominio.",
|
|
120
120
|
"ai-dashboard.refusal.share-unknown-role": "Solo se puede compartir con un rol de este dominio.",
|
|
121
121
|
"ai-dashboard.refusal.unknown-place": "Esa tarjeta no está en su panel.",
|
|
122
122
|
"ai-dashboard.refusal.size-out-of-range": "Una tarjeta ocupa de 1 a 4 columnas y de 1 a 2 filas.",
|
package/translations/ja.json
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"ai-dashboard.share.everyone-in": "{name} 全員",
|
|
50
50
|
"ai-dashboard.share.everyone": "事業所全員",
|
|
51
51
|
"ai-dashboard.share.departments": "自分の部署",
|
|
52
|
-
"ai-dashboard.share.department-members": "在籍中の部署員",
|
|
52
|
+
"ai-dashboard.share.department-members": "在籍中の部署員(下位部署を含む)",
|
|
53
53
|
"ai-dashboard.share.roles": "ロール",
|
|
54
54
|
"ai-dashboard.share.role-holders": "このロールを持つ人",
|
|
55
55
|
"ai-dashboard.share.people": "人",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"ai-dashboard.refusal.share-unknown-scope": "不明な共有範囲です。",
|
|
117
117
|
"ai-dashboard.refusal.share-no-target": "共有の対象がありません。",
|
|
118
118
|
"ai-dashboard.refusal.share-another-domain": "他の事業所には共有できません。",
|
|
119
|
-
"ai-dashboard.refusal.share-
|
|
119
|
+
"ai-dashboard.refusal.share-unknown-department": "この事業所にない部署には共有できません。",
|
|
120
120
|
"ai-dashboard.refusal.share-unknown-role": "この事業所にないロールには共有できません。",
|
|
121
121
|
"ai-dashboard.refusal.unknown-place": "そのカードはダッシュボードにありません。",
|
|
122
122
|
"ai-dashboard.refusal.size-out-of-range": "カードは横1〜4列、縦1〜2行です。",
|
package/translations/ko.json
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"ai-dashboard.share.everyone-in": "{name} 전체",
|
|
50
50
|
"ai-dashboard.share.everyone": "사업장 전체",
|
|
51
51
|
"ai-dashboard.share.departments": "내 부서",
|
|
52
|
-
"ai-dashboard.share.department-members": "재직 중인 부서원",
|
|
52
|
+
"ai-dashboard.share.department-members": "재직 중인 부서원(하위 부서 포함)",
|
|
53
53
|
"ai-dashboard.share.roles": "역할",
|
|
54
54
|
"ai-dashboard.share.role-holders": "이 역할을 가진 사람",
|
|
55
55
|
"ai-dashboard.share.people": "사람",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"ai-dashboard.refusal.share-unknown-scope": "알 수 없는 공유 범위입니다.",
|
|
117
117
|
"ai-dashboard.refusal.share-no-target": "공유할 대상이 없습니다.",
|
|
118
118
|
"ai-dashboard.refusal.share-another-domain": "다른 사업장에는 공유할 수 없습니다.",
|
|
119
|
-
"ai-dashboard.refusal.share-
|
|
119
|
+
"ai-dashboard.refusal.share-unknown-department": "이 사업장에 없는 부서에는 공유할 수 없습니다.",
|
|
120
120
|
"ai-dashboard.refusal.share-unknown-role": "사업장에 없는 역할에는 공유할 수 없습니다.",
|
|
121
121
|
"ai-dashboard.refusal.unknown-place": "그 카드는 대시보드에 없습니다.",
|
|
122
122
|
"ai-dashboard.refusal.size-out-of-range": "카드는 가로 1~4칸, 세로 1~2줄입니다.",
|
package/translations/ms.json
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"ai-dashboard.share.everyone-in": "Semua dalam {name}",
|
|
50
50
|
"ai-dashboard.share.everyone": "semua",
|
|
51
51
|
"ai-dashboard.share.departments": "Jabatan saya",
|
|
52
|
-
"ai-dashboard.share.department-members": "ahli aktif",
|
|
52
|
+
"ai-dashboard.share.department-members": "ahli aktif, termasuk sub-jabatan",
|
|
53
53
|
"ai-dashboard.share.roles": "Peranan",
|
|
54
54
|
"ai-dashboard.share.role-holders": "sesiapa yang memegang peranan ini",
|
|
55
55
|
"ai-dashboard.share.people": "Orang",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"ai-dashboard.refusal.share-unknown-scope": "Skop perkongsian tidak diketahui.",
|
|
117
117
|
"ai-dashboard.refusal.share-no-target": "Perkongsian tidak menamakan sesiapa.",
|
|
118
118
|
"ai-dashboard.refusal.share-another-domain": "Kad tidak boleh dikongsi dengan tapak lain.",
|
|
119
|
-
"ai-dashboard.refusal.share-
|
|
119
|
+
"ai-dashboard.refusal.share-unknown-department": "Kad hanya boleh dikongsi dengan jabatan dalam domain ini.",
|
|
120
120
|
"ai-dashboard.refusal.share-unknown-role": "Kad hanya boleh dikongsi dengan peranan dalam domain ini.",
|
|
121
121
|
"ai-dashboard.refusal.unknown-place": "Kad itu tiada pada papan pemuka anda.",
|
|
122
122
|
"ai-dashboard.refusal.size-out-of-range": "Kad merangkumi 1 hingga 4 lajur dan 1 hingga 2 baris.",
|
package/translations/zh.json
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"ai-dashboard.share.everyone-in": "{name} 全体",
|
|
50
50
|
"ai-dashboard.share.everyone": "全体",
|
|
51
51
|
"ai-dashboard.share.departments": "我的部门",
|
|
52
|
-
"ai-dashboard.share.department-members": "在职部门成员",
|
|
52
|
+
"ai-dashboard.share.department-members": "在职部门成员(含下级部门)",
|
|
53
53
|
"ai-dashboard.share.roles": "角色",
|
|
54
54
|
"ai-dashboard.share.role-holders": "拥有此角色的人",
|
|
55
55
|
"ai-dashboard.share.people": "人员",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"ai-dashboard.refusal.share-unknown-scope": "未知的共享范围。",
|
|
117
117
|
"ai-dashboard.refusal.share-no-target": "共享没有指定对象。",
|
|
118
118
|
"ai-dashboard.refusal.share-another-domain": "不能共享到其他业务单位。",
|
|
119
|
-
"ai-dashboard.refusal.share-
|
|
119
|
+
"ai-dashboard.refusal.share-unknown-department": "只能共享给本域中的部门。",
|
|
120
120
|
"ai-dashboard.refusal.share-unknown-role": "只能共享给本域中的角色。",
|
|
121
121
|
"ai-dashboard.refusal.unknown-place": "该卡片不在您的仪表板上。",
|
|
122
122
|
"ai-dashboard.refusal.size-out-of-range": "卡片横跨1至4列、1至2行。",
|