@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.
Files changed (37) hide show
  1. package/client/components/card-dialogs.ts +1 -1
  2. package/client/utils/card-api.ts +1 -2
  3. package/dist-client/components/card-dialogs.js +1 -1
  4. package/dist-client/components/card-dialogs.js.map +1 -1
  5. package/dist-client/tsconfig.tsbuildinfo +1 -1
  6. package/dist-client/utils/card-api.d.ts +0 -1
  7. package/dist-client/utils/card-api.js +1 -1
  8. package/dist-client/utils/card-api.js.map +1 -1
  9. package/dist-server/service/card/card-access.d.ts +4 -33
  10. package/dist-server/service/card/card-access.js +6 -35
  11. package/dist-server/service/card/card-access.js.map +1 -1
  12. package/dist-server/service/card/card-resolver.js +11 -34
  13. package/dist-server/service/card/card-resolver.js.map +1 -1
  14. package/dist-server/service/card/card-run.d.ts +2 -0
  15. package/dist-server/service/card/card-run.js +7 -4
  16. package/dist-server/service/card/card-run.js.map +1 -1
  17. package/dist-server/service/card/card-tool.js +8 -12
  18. package/dist-server/service/card/card-tool.js.map +1 -1
  19. package/dist-server/service/card/card-types.d.ts +0 -1
  20. package/dist-server/service/card/card-types.js +0 -4
  21. package/dist-server/service/card/card-types.js.map +1 -1
  22. package/dist-server/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +4 -3
  24. package/server/service/card/card-access.test.ts +5 -25
  25. package/server/service/card/card-access.ts +7 -43
  26. package/server/service/card/card-resolver.ts +15 -36
  27. package/server/service/card/card-run.ts +10 -5
  28. package/server/service/card/card-tool.ts +8 -11
  29. package/server/service/card/card-types.ts +0 -3
  30. package/server/service/card/card-words.test.ts +72 -0
  31. package/test/translations.test.ts +1 -1
  32. package/translations/en.json +2 -2
  33. package/translations/es.json +2 -2
  34. package/translations/ja.json +2 -2
  35. package/translations/ko.json +2 -2
  36. package/translations/ms.json +2 -2
  37. 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.221",
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.221",
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": "90e211d7a2c99216e017bd19a62e1703b20f2710"
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, departmentsOfEmployees, shareRefusal } from './card-access'
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 is one an active employee record is in - an inactive or unset record shares nothing', () => {
42
- expect(
43
- departmentsOfEmployees([
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 interface CardShareRow {
13
- scope: 'USER' | 'DEPARTMENT' | 'ROLE' | 'DOMAIN' | string
14
- targetId: string
15
- }
15
+ export type CardShareRow = AudienceTarget
16
16
 
17
- export interface CardViewer {
18
- userId: string
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
- const shared = shares.some(
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 { applyDomainInheritanceFilter, getRepository, InheritedValueType } from '@things-factory/shell'
14
- import { Role, User } from '@things-factory/auth-base'
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, departmentsOfEmployees, shareRefusal, type CardAccess, type CardViewer } from './card-access.js'
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 roles of the domain - the ones it inherits from its parent included, as its privileges are (User.getPrivilegesByDomain);
52
- * `holder` keeps those one person holds.
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 domainRoles(context: ResolverContext, holder?: string, tx?: any): Promise<Role[]> {
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
- let records: any[] | undefined
65
- try {
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 { departmentsAvailable, departments } = await viewerOf(context)
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 domainRoles(context)).map(one => ({ id: one.id, name: one.name, ...(one.description ? { note: one.description } : {}) }))
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 { departmentsAvailable } = await viewerOf(context)
385
- const roleIds = (shares ?? []).some(one => one.scope === DashboardCardShareScope.ROLE) ? (await domainRoles(context, undefined, tx)).map(one => one.id) : []
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 = shareRefusal(share, { domainId: domain.id, departmentsAvailable, roleIds })
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
- rows: rows.map(row => Object.fromEntries(drawn.map(column => [column.name, row[column.name] ?? null])))
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, departmentsOfEmployees }] = await Promise.all([
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
- let records: any[] | undefined
327
- try {
328
- const answer = await runAsCaller(context)('query { myEmployees { active department { id } } }', {})
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', 'no-departments', 'unknown-role']) used.add(`ai-dashboard.refusal.share-${code}`)
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}`)
@@ -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-no-departments": "This application has no departments to share with.",
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.",
@@ -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-no-departments": "Esta aplicación no tiene departamentos con los que compartir.",
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.",
@@ -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-no-departments": "このアプリには部署がないため、部署に共有できません。",
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行です。",
@@ -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-no-departments": "이 앱에는 부서가 없어 부서로 공유할 수 없습니다.",
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줄입니다.",
@@ -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-no-departments": "Aplikasi ini tiada jabatan untuk dikongsi.",
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.",
@@ -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-no-departments": "此应用没有部门,无法按部门共享。",
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行。",