@company-semantics/contracts 6.6.0 → 7.1.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.6.0",
3
+ "version": "7.1.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 = '937aecbfe6e6' as const;
3
+ export const SPEC_HASH_FULL = '937aecbfe6e6533edabb1b83d66be83f63100e519ecf9e4531af3e8a627e122a' as const;
@@ -1972,6 +1972,30 @@ export interface paths {
1972
1972
  patch?: never;
1973
1973
  trace?: never;
1974
1974
  };
1975
+ "/api/users/{userId}/avatar": {
1976
+ parameters: {
1977
+ query?: never;
1978
+ header?: never;
1979
+ path?: never;
1980
+ cookie?: never;
1981
+ };
1982
+ get?: never;
1983
+ /**
1984
+ * Upload (replace) a user's avatar photo from an image file
1985
+ * @description Accepts a multipart image upload (PNG/JPEG/WebP/HEIC) up to 5 MB. The caller must be the target user or hold org.manage_users. HEIC/HEIF is converted to JPEG and the bytes are re-hosted via the blob store. Returns the resolved avatar.
1986
+ */
1987
+ put: operations["uploadUserAvatar"];
1988
+ post?: never;
1989
+ /**
1990
+ * Clear a user's uploaded avatar (reverts to Slack photo or initials)
1991
+ * @description Clears an in-app uploaded avatar only (source=upload); Slack-sourced avatars are left intact. The caller must be the target user or hold org.manage_users. Returns the resolved avatar after reverting.
1992
+ */
1993
+ delete: operations["deleteUserAvatar"];
1994
+ options?: never;
1995
+ head?: never;
1996
+ patch?: never;
1997
+ trace?: never;
1998
+ };
1975
1999
  "/api/drive/files": {
1976
2000
  parameters: {
1977
2001
  query?: never;
@@ -2782,7 +2806,7 @@ export interface components {
2782
2806
  slackUserId: string | null;
2783
2807
  avatar: {
2784
2808
  /** @enum {string} */
2785
- source: "slack" | "initials";
2809
+ source: "photo" | "initials";
2786
2810
  url?: string;
2787
2811
  initials: string;
2788
2812
  };
@@ -4067,7 +4091,7 @@ export interface components {
4067
4091
  userId: string;
4068
4092
  fullName: string;
4069
4093
  jobTitle: string | null;
4070
- slackAvatarUrl: string | null;
4094
+ avatarUrl: string | null;
4071
4095
  authorities: {
4072
4096
  /** @enum {string} */
4073
4097
  mechanism: "structural" | "delegated" | "rbac";
@@ -4078,6 +4102,7 @@ export interface components {
4078
4102
  /** Format: uuid */
4079
4103
  derivedFromUnitId: string;
4080
4104
  derivedFromUnitName?: string;
4105
+ derivedFromRoot?: boolean;
4081
4106
  derivedFromUserId?: string | null;
4082
4107
  explanation: string;
4083
4108
  derivationMetadata?: {
@@ -4093,7 +4118,7 @@ export interface components {
4093
4118
  userId: string;
4094
4119
  fullName: string;
4095
4120
  jobTitle: string | null;
4096
- slackAvatarUrl: string | null;
4121
+ avatarUrl: string | null;
4097
4122
  authorities: {
4098
4123
  /** @enum {string} */
4099
4124
  mechanism: "structural" | "delegated" | "rbac";
@@ -4104,6 +4129,7 @@ export interface components {
4104
4129
  /** Format: uuid */
4105
4130
  derivedFromUnitId: string;
4106
4131
  derivedFromUnitName?: string;
4132
+ derivedFromRoot?: boolean;
4107
4133
  derivedFromUserId?: string | null;
4108
4134
  explanation: string;
4109
4135
  derivationMetadata?: {
@@ -4226,7 +4252,7 @@ export interface components {
4226
4252
  id: string;
4227
4253
  fullName: string;
4228
4254
  jobTitle: string | null;
4229
- slackAvatarUrl: string | null;
4255
+ avatarUrl: string | null;
4230
4256
  primaryUnitId: string | null;
4231
4257
  }[];
4232
4258
  edges: {
@@ -7921,6 +7947,101 @@ export interface operations {
7921
7947
  };
7922
7948
  };
7923
7949
  };
7950
+ uploadUserAvatar: {
7951
+ parameters: {
7952
+ query?: never;
7953
+ header?: never;
7954
+ path: {
7955
+ userId: string;
7956
+ };
7957
+ cookie?: never;
7958
+ };
7959
+ requestBody: {
7960
+ content: {
7961
+ "multipart/form-data": {
7962
+ [key: string]: unknown;
7963
+ };
7964
+ };
7965
+ };
7966
+ responses: {
7967
+ /** @description Avatar uploaded; resolved avatar returned */
7968
+ 200: {
7969
+ headers: {
7970
+ [name: string]: unknown;
7971
+ };
7972
+ content?: never;
7973
+ };
7974
+ /** @description Missing or malformed multipart upload */
7975
+ 400: {
7976
+ headers: {
7977
+ [name: string]: unknown;
7978
+ };
7979
+ content?: never;
7980
+ };
7981
+ /** @description Caller may not change this avatar */
7982
+ 403: {
7983
+ headers: {
7984
+ [name: string]: unknown;
7985
+ };
7986
+ content?: never;
7987
+ };
7988
+ /** @description Target user is not a member of the caller org */
7989
+ 404: {
7990
+ headers: {
7991
+ [name: string]: unknown;
7992
+ };
7993
+ content?: never;
7994
+ };
7995
+ /** @description Upload exceeds the 5 MB size limit */
7996
+ 413: {
7997
+ headers: {
7998
+ [name: string]: unknown;
7999
+ };
8000
+ content?: never;
8001
+ };
8002
+ /** @description Unsupported image type or bytes are not a valid image */
8003
+ 415: {
8004
+ headers: {
8005
+ [name: string]: unknown;
8006
+ };
8007
+ content?: never;
8008
+ };
8009
+ };
8010
+ };
8011
+ deleteUserAvatar: {
8012
+ parameters: {
8013
+ query?: never;
8014
+ header?: never;
8015
+ path: {
8016
+ userId: string;
8017
+ };
8018
+ cookie?: never;
8019
+ };
8020
+ requestBody?: never;
8021
+ responses: {
8022
+ /** @description Uploaded avatar cleared; resolved avatar returned */
8023
+ 200: {
8024
+ headers: {
8025
+ [name: string]: unknown;
8026
+ };
8027
+ content?: never;
8028
+ };
8029
+ /** @description Caller may not change this avatar */
8030
+ 403: {
8031
+ headers: {
8032
+ [name: string]: unknown;
8033
+ };
8034
+ content?: never;
8035
+ };
8036
+ /** @description Target user is not a member of the caller org */
8037
+ 404: {
8038
+ headers: {
8039
+ [name: string]: unknown;
8040
+ };
8041
+ content?: never;
8042
+ };
8043
+ };
8044
+ };
7924
8045
  listDriveFiles: {
7925
8046
  parameters: {
7926
8047
  query?: never;
@@ -117,6 +117,7 @@ export const openApiRoutes = {
117
117
  '/api/users/org-chart': ['GET'],
118
118
  '/api/users/org-chart/import': ['POST'],
119
119
  '/api/users/org-chart/import/{operationId}/retry': ['POST'],
120
+ '/api/users/{userId}/avatar': ['DELETE', 'PUT'],
120
121
  '/api/work-items/{id}': ['GET'],
121
122
  '/api/work-items/{id}/content': ['PUT'],
122
123
  '/api/work-items/{id}/title': ['PUT'],
@@ -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
  // =============================================================================
package/src/org/index.ts CHANGED
@@ -125,6 +125,8 @@ export {
125
125
  WorkspaceOverviewSchema,
126
126
  WorkspaceMembersResponseSchema,
127
127
  WorkspaceMemberDetailSchema,
128
+ WorkspaceMemberManagesEntrySchema,
129
+ OrgUnitDesignationSchema,
128
130
  RoleCatalogEntrySchema,
129
131
  RoleCatalogResponseSchema,
130
132
  WorkspaceAuthConfigSchema,
@@ -148,6 +150,8 @@ export type {
148
150
  WorkspaceAccessResponse,
149
151
  WorkspaceOverview as WorkspaceOverviewDto,
150
152
  WorkspaceMembersResponse,
153
+ WorkspaceMemberManagesEntry,
154
+ OrgUnitDesignation,
151
155
  WorkspaceAuthConfig as WorkspaceAuthConfigDto,
152
156
  WorkspaceAuditEvent as WorkspaceAuditEventDto,
153
157
  WorkspaceResolvePathResponse,
@@ -25,6 +25,31 @@ const WorkspaceMemberUnitSummarySchema = z.object({
25
25
  role: z.enum(['owner', 'manager', 'member']),
26
26
  });
27
27
 
28
+ /**
29
+ * Designation a member holds over a unit they manage (ADR-CTRL-112).
30
+ *
31
+ * Precedence, highest-first: whole_org > admin > leader > delegate > member.
32
+ * The org-wide designations (`whole_org`, `admin`) anchor to the org root
33
+ * unit. This vocabulary is DISPLAY-ONLY — it summarizes management reach for
34
+ * UI surfaces and is NEVER an authorization input (authority resolution uses
35
+ * the orthogonal membership/delegation axes, not this enum).
36
+ */
37
+ export const OrgUnitDesignationSchema = z.enum([
38
+ 'whole_org',
39
+ 'admin',
40
+ 'leader',
41
+ 'delegate',
42
+ 'member',
43
+ ]);
44
+ export type OrgUnitDesignation = z.infer<typeof OrgUnitDesignationSchema>;
45
+
46
+ export const WorkspaceMemberManagesEntrySchema = z.object({
47
+ unitId: z.string(),
48
+ unitName: z.string(),
49
+ designation: OrgUnitDesignationSchema,
50
+ });
51
+ export type WorkspaceMemberManagesEntry = z.infer<typeof WorkspaceMemberManagesEntrySchema>;
52
+
28
53
  const WorkspaceMemberSchema = z.object({
29
54
  id: z.string(),
30
55
  name: z.string(),
@@ -36,6 +61,7 @@ const WorkspaceMemberSchema = z.object({
36
61
  lastActiveAt: z.string().nullable(),
37
62
  primaryUnitId: z.string().uuid().nullable(),
38
63
  unitMemberships: z.array(WorkspaceMemberUnitSummarySchema),
64
+ manages: z.array(WorkspaceMemberManagesEntrySchema),
39
65
  unitMembershipsTruncated: z.boolean(),
40
66
  inviteStatus: z.enum(['active', 'pending', 'expired']).nullable(),
41
67
  });
@@ -867,7 +893,7 @@ export const OrgUnitOwnerSchema = z.object({
867
893
  userId: z.string().uuid(),
868
894
  fullName: z.string().min(1),
869
895
  jobTitle: z.string().nullable(),
870
- slackAvatarUrl: z.string().nullable(),
896
+ avatarUrl: z.string().nullable(),
871
897
  /** All authority grants this user holds for the unit. At least one entry. */
872
898
  authorities: z.array(OwnerAuthoritySchema).min(1),
873
899
  });