@company-semantics/contracts 6.5.0 → 7.0.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "6.5.0",
3
+ "version": "7.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = 'c8a21126db6c' as const;
3
- export const SPEC_HASH_FULL = 'c8a21126db6c84f641327e778c710c41e539cd5f0a1272d077ec54f1a6e382b2' as const;
2
+ export const SPEC_HASH = 'e20bec799e8f' as const;
3
+ export const SPEC_HASH_FULL = 'e20bec799e8f8bd1a51fccbafa626fad0f4e001789ff8b13cda65847c32972f2' as const;
@@ -4078,6 +4078,7 @@ export interface components {
4078
4078
  /** Format: uuid */
4079
4079
  derivedFromUnitId: string;
4080
4080
  derivedFromUnitName?: string;
4081
+ derivedFromRoot?: boolean;
4081
4082
  derivedFromUserId?: string | null;
4082
4083
  explanation: string;
4083
4084
  derivationMetadata?: {
@@ -4104,6 +4105,7 @@ export interface components {
4104
4105
  /** Format: uuid */
4105
4106
  derivedFromUnitId: string;
4106
4107
  derivedFromUnitName?: string;
4108
+ derivedFromRoot?: boolean;
4107
4109
  derivedFromUserId?: string | null;
4108
4110
  explanation: string;
4109
4111
  derivationMetadata?: {
@@ -36,39 +36,39 @@ describe('generateInitials', () => {
36
36
  })
37
37
 
38
38
  describe('resolveAvatar', () => {
39
- it('initials is always populated when source is slack (critical invariant)', () => {
40
- const result = resolveAvatar({ slackAvatarUrl: 'https://example.com/img.jpg', fullName: 'Ian Heidt' })
41
- expect(result.source).toBe('slack')
39
+ it('initials is always populated when source is photo (critical invariant)', () => {
40
+ const result = resolveAvatar({ avatarUrl: 'https://example.com/img.jpg', fullName: 'Ian Heidt' })
41
+ expect(result.source).toBe('photo')
42
42
  expect(result.url).toBe('https://example.com/img.jpg')
43
43
  // INVARIANT: initials is ALWAYS populated, regardless of source.
44
- // This prevents UI regressions when Slack avatars fail to load.
44
+ // This prevents UI regressions when the photo fails to load.
45
45
  expect(typeof result.initials).toBe('string')
46
46
  expect(result.initials).toBe('IH')
47
47
  })
48
48
 
49
- it('returns initials source when no slackAvatarUrl', () => {
49
+ it('returns initials source when no avatarUrl', () => {
50
50
  const result = resolveAvatar({ fullName: 'Ian Heidt' } as any)
51
51
  expect(result.source).toBe('initials')
52
52
  expect(result.initials).toBe('IH')
53
53
  expect(result.url).toBeUndefined()
54
54
  })
55
55
 
56
- it('returns slack source with empty initials when fullName is empty', () => {
57
- const result = resolveAvatar({ slackAvatarUrl: 'https://example.com/img.jpg', fullName: '' })
58
- expect(result.source).toBe('slack')
56
+ it('returns photo source with empty initials when fullName is empty', () => {
57
+ const result = resolveAvatar({ avatarUrl: 'https://example.com/img.jpg', fullName: '' })
58
+ expect(result.source).toBe('photo')
59
59
  expect(result.url).toBe('https://example.com/img.jpg')
60
60
  // Empty is valid — just not undefined
61
61
  expect(result.initials).toBe('')
62
62
  })
63
63
 
64
- it('returns initials source when slackAvatarUrl is explicitly undefined', () => {
65
- const result = resolveAvatar({ slackAvatarUrl: undefined, fullName: 'Ian Heidt' })
64
+ it('returns initials source when avatarUrl is explicitly undefined', () => {
65
+ const result = resolveAvatar({ avatarUrl: undefined, fullName: 'Ian Heidt' })
66
66
  expect(result.source).toBe('initials')
67
67
  expect(result.initials).toBe('IH')
68
68
  })
69
69
 
70
- it('returns initials source when slackAvatarUrl is empty string (falsy)', () => {
71
- const result = resolveAvatar({ slackAvatarUrl: '', fullName: 'Ian Heidt' })
70
+ it('returns initials source when avatarUrl is empty string (falsy)', () => {
71
+ const result = resolveAvatar({ avatarUrl: '', fullName: 'Ian Heidt' })
72
72
  expect(result.source).toBe('initials')
73
73
  expect(result.initials).toBe('IH')
74
74
  })
@@ -2,7 +2,7 @@
2
2
  * Avatar Resolution Functions
3
3
  *
4
4
  * Canonical functions for resolving user avatars.
5
- * Determines whether to use Slack profile photo or initials fallback.
5
+ * Determines whether to use a resolved profile photo or initials fallback.
6
6
  *
7
7
  * @see ADR-BE-063 for design rationale
8
8
  */
@@ -15,22 +15,22 @@ import type { UserIdentity } from './types';
15
15
 
16
16
  /**
17
17
  * Source of the resolved avatar.
18
- * - 'slack': Using Slack profile photo
18
+ * - 'photo': Using a resolved profile photo
19
19
  * - 'initials': Using generated initials (fallback)
20
20
  */
21
- export type AvatarSource = 'slack' | 'initials';
21
+ export type AvatarSource = 'photo' | 'initials';
22
22
 
23
23
  /**
24
24
  * Resolved avatar for UI rendering.
25
25
  *
26
26
  * INVARIANT: initials is ALWAYS populated, regardless of source.
27
- * This prevents UI regressions if Slack avatar fails to load.
27
+ * This prevents UI regressions if the photo fails to load.
28
28
  */
29
29
  export interface ResolvedAvatar {
30
- /** Source of the avatar (slack or initials) */
30
+ /** Source of the avatar (photo or initials) */
31
31
  source: AvatarSource;
32
32
 
33
- /** Slack profile image URL (present only if source === 'slack') */
33
+ /** resolved photo URL (present only if source === photo) */
34
34
  url?: string;
35
35
 
36
36
  /**
@@ -85,29 +85,29 @@ export function generateInitials(fullName: string): string {
85
85
  * Returns a ResolvedAvatar with source, optional URL, and required initials.
86
86
  *
87
87
  * INVARIANT: initials is ALWAYS populated, regardless of source.
88
- * This ensures the UI always has a fallback if the Slack image fails to load.
88
+ * This ensures the UI always has a fallback if the photo fails to load.
89
89
  *
90
- * @param identity - Object with slackAvatarUrl and fullName fields
91
- * @returns ResolvedAvatar with source, url (if slack), and initials
90
+ * @param identity - Object with avatarUrl and fullName fields
91
+ * @returns ResolvedAvatar with source, url (if photo), and initials
92
92
  *
93
93
  * @example
94
- * // Slack avatar available
95
- * resolveAvatar({ slackAvatarUrl: "https://...", fullName: "Ian Heidt" })
96
- * // { source: 'slack', url: 'https://...', initials: 'IH' }
94
+ * // Photo avatar available
95
+ * resolveAvatar({ avatarUrl: "https://...", fullName: "Ian Heidt" })
96
+ * // { source: 'photo', url: 'https://...', initials: 'IH' }
97
97
  *
98
- * // No Slack avatar (initials fallback)
98
+ * // No photo avatar (initials fallback)
99
99
  * resolveAvatar({ fullName: "Ian Heidt" })
100
100
  * // { source: 'initials', initials: 'IH' }
101
101
  */
102
102
  export function resolveAvatar(
103
- identity: Pick<UserIdentity, 'slackAvatarUrl' | 'fullName'>
103
+ identity: Pick<UserIdentity, 'avatarUrl' | 'fullName'>
104
104
  ): ResolvedAvatar {
105
105
  const initials = generateInitials(identity.fullName);
106
106
 
107
- if (identity.slackAvatarUrl) {
107
+ if (identity.avatarUrl) {
108
108
  return {
109
- source: 'slack',
110
- url: identity.slackAvatarUrl,
109
+ source: 'photo',
110
+ url: identity.avatarUrl,
111
111
  initials,
112
112
  };
113
113
  }
@@ -28,7 +28,7 @@ export const PeopleOrgChartNodeSchema = z.object({
28
28
  id: z.string().uuid(),
29
29
  fullName: z.string(),
30
30
  jobTitle: z.string().nullable(),
31
- slackAvatarUrl: z.string().nullable(),
31
+ avatarUrl: z.string().nullable(),
32
32
  primaryUnitId: z.string().uuid().nullable(),
33
33
  });
34
34
 
@@ -15,7 +15,7 @@ import { z } from 'zod';
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
17
  const ResolvedAvatarSchema = z.object({
18
- source: z.enum(['slack', 'initials']),
18
+ source: z.enum(['photo', 'initials']),
19
19
  url: z.string().optional(),
20
20
  initials: z.string(),
21
21
  });
@@ -79,26 +79,46 @@ export type UserIdentity = {
79
79
  updatedAt: ISODateString;
80
80
 
81
81
  // ==========================================================================
82
- // Slack Avatar Fields (ADR-BE-063)
82
+ // Avatar Fields (source-agnostic; see ADR-BE-063 for original Slack origin)
83
83
  // ==========================================================================
84
+ //
85
+ // The avatar model is source-agnostic: avatarUrl is the resolved profile
86
+ // image regardless of where it came from, avatarSource records the stored
87
+ // origin (if any), and avatarUpdatedAt records when it last changed. Avatar
88
+ // rendering depends only on avatarUrl (falling back to generated initials);
89
+ // avatarSource/avatarUpdatedAt are metadata for sync and audit.
84
90
 
85
91
  /**
86
- * Slack user ID (set when Slack is connected and email matches).
87
- * Used for efficient lookups in user_change event handling.
92
+ * Source-agnostic resolved profile image URL.
93
+ * When present, avatar resolution uses this photo (source === 'photo');
94
+ * otherwise it falls back to generated initials.
88
95
  */
89
- slackUserId?: string;
96
+ avatarUrl?: string;
90
97
 
91
98
  /**
92
- * Slack profile image URL (synced from Slack events).
93
- * May be undefined if Slack not connected or email mismatch.
99
+ * Stored origin of the avatar image.
100
+ * Absent when there is no stored avatar (resolution falls back to initials).
101
+ * - 'slack': synced from a connected Slack workspace
102
+ * - 'upload': uploaded directly by the user
94
103
  */
95
- slackAvatarUrl?: string;
104
+ avatarSource?: 'slack' | 'upload';
96
105
 
97
106
  /**
98
- * When the Slack avatar was last synced.
107
+ * When the avatar was last updated (synced or uploaded).
99
108
  * Used for debugging and audit purposes.
100
109
  */
101
- slackAvatarLastSyncedAt?: ISODateString;
110
+ avatarUpdatedAt?: ISODateString;
111
+
112
+ // ==========================================================================
113
+ // Slack Identity Fields (ADR-BE-063)
114
+ // ==========================================================================
115
+
116
+ /**
117
+ * Slack user ID (set when Slack is connected and email matches).
118
+ * Used for efficient lookups in user_change event handling.
119
+ * This is Slack identity, not avatar data — retained independently of avatar source.
120
+ */
121
+ slackUserId?: string;
102
122
  };
103
123
 
104
124
  // =============================================================================
@@ -826,6 +826,14 @@ export const OwnerAuthoritySchema = z.object({
826
826
  mutability: AuthorityMutabilitySchema,
827
827
  derivedFromUnitId: z.string().uuid(),
828
828
  derivedFromUnitName: z.string().min(1).optional(),
829
+ /**
830
+ * True when this authority derives from the org ROOT unit — i.e. the holder
831
+ * is a CEO (a structural leader of the org root, observed locally on the root
832
+ * or as inherited authority on a descendant). Lets consumers classify CEO
833
+ * without a separate org-tree lookup, the same way `mechanism === 'rbac'`
834
+ * identifies an org admin. Omitted (falsy) for non-root derivations.
835
+ */
836
+ derivedFromRoot: z.boolean().optional(),
829
837
  derivedFromUserId: z.string().uuid().nullable().optional(),
830
838
  /**
831
839
  * Human-readable reason this authority applies, built at serialize time from
@@ -859,7 +867,7 @@ export const OrgUnitOwnerSchema = z.object({
859
867
  userId: z.string().uuid(),
860
868
  fullName: z.string().min(1),
861
869
  jobTitle: z.string().nullable(),
862
- slackAvatarUrl: z.string().nullable(),
870
+ avatarUrl: z.string().nullable(),
863
871
  /** All authority grants this user holds for the unit. At least one entry. */
864
872
  authorities: z.array(OwnerAuthoritySchema).min(1),
865
873
  });