@butinapp/shapes 0.1.1 → 0.1.2

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/dist/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export * from './overview.js';
2
2
  export * from './ledger.js';
3
3
  export * from './manifest.js';
4
4
  export * from './daily.js';
5
+ export * from './people.js';
5
6
  export type * from './plugin-view.js';
package/dist/index.js CHANGED
@@ -7,3 +7,4 @@ export * from './overview.js';
7
7
  export * from './ledger.js';
8
8
  export * from './manifest.js';
9
9
  export * from './daily.js';
10
+ export * from './people.js';
@@ -0,0 +1,41 @@
1
+ import { type CapabilityResult, type TableDataset } from '@butinapp/sdk/data';
2
+ export type ServiceMembers = {
3
+ pluginId: string;
4
+ serviceName: string;
5
+ asOf?: string;
6
+ rows: Record<string, unknown>[];
7
+ };
8
+ export type PersonRow = {
9
+ personId: string;
10
+ name: string | null;
11
+ email: string | null;
12
+ services: number;
13
+ };
14
+ export type AccessRow = {
15
+ personId: string;
16
+ service: string;
17
+ role: string | null;
18
+ asOf: string | null;
19
+ };
20
+ export type MergedPeople = {
21
+ people: PersonRow[];
22
+ access: AccessRow[];
23
+ };
24
+ export declare const mergePeople: (inputs: ServiceMembers[]) => MergedPeople;
25
+ export declare const membersDataset: (result: CapabilityResult | null) => TableDataset | null;
26
+ export type ServiceCapResult = {
27
+ id: string;
28
+ result: CapabilityResult | null;
29
+ asOf?: string;
30
+ };
31
+ export declare const serviceMembersFrom: (source: {
32
+ pluginId: string;
33
+ serviceName: string;
34
+ caps: ServiceCapResult[];
35
+ }) => ServiceMembers | null;
36
+ export declare const buildPeopleResult: (merged: MergedPeople) => CapabilityResult;
37
+ export declare const buildPeopleData: (inputs: ServiceMembers[]) => {
38
+ result: CapabilityResult;
39
+ people: number;
40
+ services: number;
41
+ } | null;
package/dist/people.js ADDED
@@ -0,0 +1,110 @@
1
+ import { capabilityResult, table } from '@butinapp/sdk/data';
2
+ const str = (v) => (typeof v === 'string' && v.length > 0 ? v : null);
3
+ // The most common non-null name a person appears under; first-seen wins a tie so the pick is stable.
4
+ const majorityName = (names) => {
5
+ let best = null;
6
+ let bestCount = 0;
7
+ const counts = new Map();
8
+ for (const name of names) {
9
+ const count = (counts.get(name) ?? 0) + 1;
10
+ counts.set(name, count);
11
+ if (count > bestCount) {
12
+ best = name;
13
+ bestCount = count;
14
+ }
15
+ }
16
+ return best;
17
+ };
18
+ // Merge every service's member roster into people. Rows sharing a case-insensitive email are one person;
19
+ // a row without an email stays its own entry (keyed by service + row id) so name-only rows never falsely
20
+ // merge. Access rows carry the raw per-service role plus the report's fetch time.
21
+ export const mergePeople = (inputs) => {
22
+ const byId = new Map();
23
+ for (const svc of inputs) {
24
+ svc.rows.forEach((row, index) => {
25
+ const email = str(row.email);
26
+ const name = str(row.name);
27
+ // A row with neither a name nor an email identifies nobody (a service/placeholder account some rosters
28
+ // carry) — drop it rather than showing a blank "— / —" person in the audit.
29
+ if (!email && !name) {
30
+ return;
31
+ }
32
+ const rowId = str(row.id) ?? String(index);
33
+ const personId = email ? email.trim().toLowerCase() : `${svc.pluginId}:${rowId}`;
34
+ let acc = byId.get(personId);
35
+ if (!acc) {
36
+ acc = { personId, email, names: [], pluginIds: new Set(), access: [] };
37
+ byId.set(personId, acc);
38
+ }
39
+ if (name) {
40
+ acc.names.push(name);
41
+ }
42
+ acc.pluginIds.add(svc.pluginId);
43
+ acc.access.push({ personId, service: svc.serviceName, role: str(row.role), asOf: svc.asOf ?? null });
44
+ });
45
+ }
46
+ const accs = [...byId.values()];
47
+ const people = accs.map((a) => ({
48
+ personId: a.personId,
49
+ name: majorityName(a.names),
50
+ email: a.email,
51
+ services: a.pluginIds.size
52
+ }));
53
+ const label = (p) => p.name ?? p.email ?? p.personId;
54
+ people.sort((x, y) => y.services - x.services || label(x).localeCompare(label(y)));
55
+ const access = accs.flatMap((a) => a.access);
56
+ access.sort((x, y) => x.service.localeCompare(y.service));
57
+ return { people, access };
58
+ };
59
+ // The standard roster the members.result preset emits: a table with id 'members' that declares an email
60
+ // column. The email-column requirement excludes members-shaped usage breakdowns that reuse the id with
61
+ // different fields (per-member spend tables carry no email).
62
+ export const membersDataset = (result) => result?.datasets.find((d) => d.shape === 'table' && d.id === 'members' && d.columns.some((c) => c.key === 'email')) ?? null;
63
+ // Pick a service's roster from its capability results: prefer the `members` capability, else the first result
64
+ // carrying a members dataset. Returns null when the service reports no roster. The winning capability's fetch
65
+ // time becomes the contribution's `asOf`.
66
+ export const serviceMembersFrom = (source) => {
67
+ const ordered = [...source.caps].sort((a, b) => Number(b.id === 'members') - Number(a.id === 'members'));
68
+ for (const cap of ordered) {
69
+ const ds = membersDataset(cap.result);
70
+ if (ds) {
71
+ return { pluginId: source.pluginId, serviceName: source.serviceName, asOf: cap.asOf, rows: ds.rows };
72
+ }
73
+ }
74
+ return null;
75
+ };
76
+ // Shape the merged rollup into the one renderable result the People page draws: a people table whose rows
77
+ // expand into that person's per-service access, joined on the hidden personId.
78
+ export const buildPeopleResult = (merged) => {
79
+ const people = table({
80
+ id: 'people',
81
+ columns: [
82
+ { key: 'name', role: 'label', label: 'Name' },
83
+ { key: 'email', role: 'identifier', label: 'Email' },
84
+ { key: 'services', role: 'count', label: 'Services' },
85
+ { key: 'personId', role: 'identifier', hidden: true }
86
+ ],
87
+ rows: merged.people,
88
+ key: 'personId'
89
+ });
90
+ const access = table({
91
+ id: 'access',
92
+ columns: [
93
+ { key: 'service', role: 'label', label: 'Service' },
94
+ { key: 'role', role: 'category', label: 'Role' },
95
+ { key: 'asOf', role: 'timestamp', label: 'As of' },
96
+ { key: 'personId', role: 'identifier', hidden: true }
97
+ ],
98
+ rows: merged.access
99
+ });
100
+ return capabilityResult({ sections: [people.table({ title: 'People', detail: { rows: access, on: 'personId' } })] });
101
+ };
102
+ // Assemble the People payload from every service's roster contributions: merge, shape, and count. Returns null
103
+ // when no service reports a roster.
104
+ export const buildPeopleData = (inputs) => {
105
+ if (inputs.length === 0) {
106
+ return null;
107
+ }
108
+ const merged = mergePeople(inputs);
109
+ return { result: buildPeopleResult(merged), people: merged.people.length, services: inputs.length };
110
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butinapp/shapes",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Butin's host-layer data shapes built on the @butinapp/sdk data-view contract: the cross-service Overview input, the persisted ledger, the presentation manifest, and the portable export-bundle wire format.",
5
5
  "homepage": "https://butin.app",
6
6
  "bugs": "https://github.com/butinapp/butin/issues",
@@ -37,7 +37,7 @@
37
37
  "lodash-es": "^4.18.1",
38
38
  "luxon": "^3.7.2",
39
39
  "zod": "^4.3.6",
40
- "@butinapp/sdk": "0.1.1"
40
+ "@butinapp/sdk": "0.1.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "vitest": "^4.1.7"