@shuffl/api-contracts 0.1.0 → 0.2.1

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.
@@ -54,7 +54,7 @@ const foundation = [
54
54
  ],
55
55
  ['users.get', 'users:read', 'read', 'Read a person'],
56
56
  ['users.list', 'users:read', 'read', 'List people'],
57
- ['workspaces.current', null, 'read', 'Read this token’s workspace'],
57
+ ['teams.current', null, 'read', 'Read this token’s team'],
58
58
  ];
59
59
  // Permissions checked by another Shuffl protocol rather than a public HTTP
60
60
  // procedure (#187): one API key reaches the MCP server only when it carries
@@ -65,13 +65,153 @@ const protocols = [
65
65
  export const protocolPermissionIds = protocols.map(([id]) => id);
66
66
  export const alwaysAllowedPermissions = [
67
67
  'permissions.list',
68
- 'workspaces.current',
68
+ 'teams.current',
69
69
  ];
70
+ // What an operation does, for the ones whose registry entry carries no
71
+ // confirmation sentence (#1151). The API key picker, MCP tool list and
72
+ // Mello's findActions all read it; a derived "Save" or "Get" left the
73
+ // assistant unable to find a directory change by its words.
74
+ const operationDescriptions = {
75
+ 'access.get': "Read one person's workspace role and app access",
76
+ 'access.slackWaiting': 'List Slack members waiting for access to Shuffl',
77
+ 'assistant.conversations': 'List your conversations with Mello',
78
+ 'assistant.delete': 'Delete one of your conversations with Mello',
79
+ 'assistant.history': 'Read the messages in one of your conversations with Mello',
80
+ 'assistant.send': 'Ask Mello a question in a conversation',
81
+ 'assistant.stop': 'Stop an answer Mello is still writing',
82
+ 'badges.decideRequest': 'Approve or decline a request for a badge',
83
+ 'badges.hideOwn': 'Hide or show one of your badges on your profile',
84
+ 'badges.ofPerson': "List a person's badges",
85
+ 'badges.request': 'Ask for a badge',
86
+ 'badges.revoke': 'Take a badge back from a person',
87
+ 'badges.setHidden': 'Hide or show a badge for the whole workspace',
88
+ 'celebrations.deliveries': 'List the celebration messages Shuffl sent',
89
+ 'celebrations.mine': 'List the celebrations you received',
90
+ 'celebrations.preferences': 'Read your birthday and work anniversary sharing preferences',
91
+ 'celebrations.preview': 'Preview a celebration message',
92
+ 'celebrations.program': 'Read the Celebrations settings',
93
+ 'celebrations.refresh': 'Look for new birthdays and work anniversaries to celebrate now',
94
+ 'celebrations.review': 'Review upcoming birthdays and work anniversaries',
95
+ 'celebrations.savePreferences': 'Save your birthday and work anniversary sharing preferences',
96
+ 'communities.archive': 'Archive a community',
97
+ 'communities.join': 'Join a community',
98
+ 'communities.leave': 'Leave a community',
99
+ 'communities.members': "List a community's members",
100
+ 'communities.ofPerson': 'List the communities a person belongs to',
101
+ 'communities.setVisibility': 'Show or hide a community on your profile',
102
+ 'communities.sync': "Sync a community's members with its Slack channel now",
103
+ 'credentials.api.list': "List the workspace's API keys and what each can do",
104
+ 'credentials.api.revoke': 'Revoke an API key',
105
+ 'exports.team': "Export the workspace's programs, people and introduction history",
106
+ 'hr.command': 'Claim, release, reply to, note, categorize, resolve or reopen an HR request',
107
+ 'hr.get': 'Read one HR request and its messages',
108
+ 'hr.list': 'List the HR requests you asked or can answer',
109
+ 'hr.readiness': 'Check whether HR requests have a destination and someone to answer them',
110
+ 'hr.renameTeam': 'Rename the HR team employees send requests to',
111
+ 'hr.settings': 'Read the HR team name and who answers HR requests',
112
+ 'hr.shareSources': 'List your recent Mello conversations that an HR request can include',
113
+ 'identities.list': 'List the Slack and web accounts linked to a person',
114
+ 'identities.slackAccounts': 'List Slack accounts that can be linked to people',
115
+ 'identities.webAccounts': 'List web accounts that can be linked to people',
116
+ 'integrations.authorize': 'Get the link that connects Slack, Google Drive or Notion to the workspace',
117
+ 'invitations.cancel': 'Cancel a pending workspace invitation',
118
+ 'invitations.candidates': 'List people in the directory who can be invited to the workspace',
119
+ 'invitations.list': 'List pending workspace invitations',
120
+ 'knowledge.connections.list': 'List connected Knowledge providers',
121
+ 'knowledge.connections.request': 'Ask an administrator to connect a Knowledge source',
122
+ 'knowledge.connections.requests': 'List requests to connect a Knowledge source',
123
+ 'knowledge.policies.coverage': 'Check which common HR policies Knowledge covers',
124
+ 'knowledge.sources.get': 'Read a Knowledge source',
125
+ 'knowledge.sources.history': "List a Knowledge source's versions",
126
+ 'knowledge.sources.importWebsite': 'Import a website page into Knowledge for review',
127
+ 'knowledge.sources.list': 'List Knowledge sources',
128
+ 'knowledge.sources.refresh': 'Refresh a Knowledge source from its connected provider',
129
+ 'knowledge.sources.save': 'Save a Knowledge source draft',
130
+ 'knowledge.sources.search': 'Search Knowledge sources by title',
131
+ 'knowledge.sources.withdraw': 'Withdraw a published Knowledge source',
132
+ 'knowledge.uploads.appendPart': 'Upload the next part of a Knowledge file',
133
+ 'knowledge.uploads.complete': 'Finish a Knowledge file upload and start reading it',
134
+ 'knowledge.uploads.create': 'Start uploading a file to Knowledge',
135
+ 'knowledge.uploads.get': 'Read the progress of a Knowledge file upload',
136
+ 'knowledge.uploads.retry': 'Retry reading a Knowledge file that failed',
137
+ 'notifications.preferences': 'Read your notification preferences',
138
+ 'notifications.save': 'Save a notification preference',
139
+ 'onboarding.answerCheckIn': 'Answer your onboarding program’s help check-in: all good, or ask HR for help',
140
+ 'onboarding.assignBuddy': 'Assign an onboarding buddy to a new hire',
141
+ 'onboarding.candidates': 'List people who could be enrolled in onboarding',
142
+ 'onboarding.enrollmentPreview': 'Preview enrolling a new hire in onboarding',
143
+ 'onboarding.mine': 'Read your own onboarding plan and buddy asks',
144
+ 'onboarding.person': "Read a new hire's onboarding plan",
145
+ 'onboarding.previewEnrollment': 'Preview the onboarding steps for a new hire',
146
+ 'onboarding.program': 'Read the Onboarding program settings',
147
+ 'onboarding.remove': 'Remove a new hire from onboarding',
148
+ 'onboarding.respondBuddy': 'Accept or decline being an onboarding buddy',
149
+ 'onboarding.retry': 'Retry a failed onboarding step',
150
+ 'onboarding.update': "Change a new hire's onboarding start date or time zone",
151
+ 'operations.activity': "Read the workspace's recent audit activity",
152
+ 'operations.status': 'Read the workspace delivery and provider health summary',
153
+ 'profiles.cover.delete': 'Remove your profile cover picture',
154
+ 'profiles.photo.delete': 'Remove your profile photo',
155
+ 'profiles.positions.delete': 'Remove a past position from your profile',
156
+ 'profiles.positions.save': 'Add or change a past position on your profile',
157
+ 'profiles.save': 'Save your own profile: pronouns, location, phone, bio, interests, links and what you are working on',
158
+ 'programs.people': 'List the people in a program and whether they take part',
159
+ 'programs.schedulePreview': "Preview a program's upcoming rounds",
160
+ 'reports.answerFeedback': "Read feedback on Mello's answers",
161
+ 'reports.connections': 'Read Connections insights',
162
+ 'reports.knowledge': 'Read Knowledge activity',
163
+ 'reports.knowledgeSource': 'Read activity for one Knowledge source',
164
+ 'reports.needsYou': 'Read HR request and Knowledge draft insights',
165
+ 'reports.onboarding': 'Read Onboarding insights',
166
+ 'reports.overview': 'Read the workspace overview figures',
167
+ 'reports.people': 'Read People insights: headcount, teams and growth',
168
+ 'runs.details': 'Read one introduction round and its pairs',
169
+ 'runs.history.get': 'Read one introduction round imported from the previous Shuffl',
170
+ 'runs.history.list': 'List introduction rounds imported from the previous Shuffl',
171
+ 'runs.list': "List a program's introduction rounds",
172
+ 'support.assign': 'Assign a Shuffl support conversation',
173
+ 'support.close': 'Close a Shuffl support conversation',
174
+ 'support.create': 'Start a support conversation with Shuffl',
175
+ 'support.get': 'Read one Shuffl support conversation',
176
+ 'support.list': "List the workspace's support conversations with Shuffl",
177
+ 'support.reply': 'Reply in a Shuffl support conversation',
178
+ 'sync.current': 'Read the People sync provider, its last sync and what it found',
179
+ 'sync.request': 'Ask for a People sync provider Shuffl does not support yet',
180
+ 'sync.run': 'Sync People from the connected provider now',
181
+ 'team.launchPost': 'Read the launch announcement for the workspace',
182
+ 'teams.connectionPermissions': 'List connections whose permissions are behind what Shuffl asks for',
183
+ 'teams.initialize': 'Finish setting up a new workspace',
184
+ 'teams.rename': 'Rename the workspace',
185
+ 'teams.saveAgentSetup': 'Choose the built-in assistant or an external agent for the workspace',
186
+ 'teams.saveSlackJoinAccess': 'Choose whether Slack members can join Shuffl on their own',
187
+ 'teams.saveSlackProfileLink': 'Turn the Shuffl profile link on Slack profiles on or off',
188
+ 'teams.setup': "Read the workspace's setup checklist",
189
+ 'teams.slackChannels': 'List community channels in the connected Slack and what each would become',
190
+ 'teams.slackJoinAccess': 'Read whether Slack members can join Shuffl on their own',
191
+ 'teams.slackProfileLink': 'Read the Shuffl profile link setting for Slack profiles',
192
+ 'teams.status': 'Pause or resume the whole workspace',
193
+ 'units.archive': 'Archive a team or department in the People directory',
194
+ 'units.list': 'List the teams and departments in the People directory',
195
+ 'units.save': 'Create or rename a team or department in the People directory',
196
+ 'users.activity': "Read a person's recent activity in Shuffl",
197
+ 'users.blockedPairs': 'List pairs of people who are never introduced',
198
+ 'users.blockPair': 'Stop two people from being introduced to each other',
199
+ 'users.details': "Read one person's People directory record",
200
+ 'users.filterOptions': 'List the teams, departments, managers and locations People can filter by',
201
+ 'users.managerHistory': "Read a person's recorded manager history",
202
+ 'users.managers': 'List the managers in the People directory',
203
+ 'users.page': 'List people in the People directory: search, or filter by team, department, manager, location, access or workspace role such as owner, admin or HR responder',
204
+ 'users.participants': 'List the people who take part in programs',
205
+ 'users.participation': 'Opt a person in to or out of a program',
206
+ 'users.profile': "Edit a person's profile as an administrator",
207
+ 'users.save': "Change someone's People directory record as an administrator: name, job title, manager, team, department or start date",
208
+ };
70
209
  function definition(id, scope, access, description) {
71
210
  return {
72
211
  access,
73
212
  alwaysAllowed: alwaysAllowedPermissions.includes(id),
74
213
  description: description ??
214
+ operationDescriptions[id] ??
75
215
  id
76
216
  .slice(id.lastIndexOf('.') + 1)
77
217
  .replace(/([A-Z])/g, ' $1')
@@ -98,6 +238,41 @@ export const permissionDefinitionSchema = z.object({
98
238
  scope: scopeSchema.nullable(),
99
239
  });
100
240
  export const permissionById = new Map(permissionCatalog.map((p) => [p.id, p]));
241
+ // #1082: what an outside agent needs to set a team up end to end. The
242
+ // MCP consent screen offers these as one choice, and the agent setup guide
243
+ // lists them. Each still runs with the connected person's role, and sending,
244
+ // publishing or inviting is marked so the agent asks first.
245
+ export const teamSetupPermissions = [
246
+ 'teams.setup',
247
+ 'teams.saveAgentSetup',
248
+ 'teams.slackChannels',
249
+ 'teams.moveSlackChannels',
250
+ 'knowledge.uploads.create',
251
+ 'knowledge.uploads.appendPart',
252
+ 'knowledge.uploads.complete',
253
+ 'knowledge.uploads.get',
254
+ 'knowledge.sources.importWebsite',
255
+ 'knowledge.sources.list',
256
+ 'knowledge.sources.get',
257
+ 'knowledge.sources.write',
258
+ 'knowledge.sources.publish',
259
+ 'assistant.send',
260
+ 'invitations.create',
261
+ 'invitations.list',
262
+ 'pulse.program',
263
+ 'pulse.saveProgram',
264
+ 'channels.list',
265
+ 'programs.options',
266
+ 'programs.create',
267
+ 'onboarding.program',
268
+ 'onboarding.saveProgram',
269
+ 'celebrations.program',
270
+ 'celebrations.preview',
271
+ 'celebrations.save',
272
+ ];
273
+ // Writes that remove, stop or withdraw something. MCP marks them destructive
274
+ // and Mello's Run control asks again before running one.
275
+ export const destructiveOperation = /\.(archive|withdraw|revoke|cancel|cancelSubscription|close|delete|disconnect|unlink|pause|blockPair|stop|stopKnownFailures)$/;
101
276
  export function scopesForPermissions(permissions) {
102
277
  return [
103
278
  ...new Set(permissions
package/dist/schemas.d.ts CHANGED
@@ -12,7 +12,7 @@ export declare const strategySchema: z.ZodEnum<{
12
12
  "unit-weighted-one-host-per-group": "unit-weighted-one-host-per-group";
13
13
  "within-unit": "within-unit";
14
14
  }>;
15
- export declare const scheduleSchema: z.ZodObject<{
15
+ export declare const nativeScheduleSchema: z.ZodObject<{
16
16
  anchor: z.ZodString;
17
17
  frequency: z.ZodEnum<{
18
18
  days: "days";
@@ -26,6 +26,120 @@ export declare const scheduleSchema: z.ZodObject<{
26
26
  interval: z.ZodNumber;
27
27
  timezone: z.ZodString;
28
28
  }, z.core.$strip>;
29
+ export declare const legacyMonthlyCalendarSchema: z.ZodObject<{
30
+ boundary: z.ZodEnum<{
31
+ end: "end";
32
+ start: "start";
33
+ }>;
34
+ hour: z.ZodNumber;
35
+ minute: z.ZodNumber;
36
+ second: z.ZodNumber;
37
+ timezone: z.ZodString;
38
+ weekday: z.ZodNumber;
39
+ }, z.core.$strict>;
40
+ export declare const legacyMonthlyScheduleSchema: z.ZodUnion<readonly [z.ZodObject<{
41
+ calendar: z.ZodObject<{
42
+ boundary: z.ZodEnum<{
43
+ end: "end";
44
+ start: "start";
45
+ }>;
46
+ hour: z.ZodNumber;
47
+ minute: z.ZodNumber;
48
+ second: z.ZodNumber;
49
+ timezone: z.ZodString;
50
+ weekday: z.ZodNumber;
51
+ }, z.core.$strict>;
52
+ frequency: z.ZodLiteral<"legacy-monthly">;
53
+ insufficientOptInsNotice: z.ZodObject<{
54
+ future: z.ZodNullable<z.ZodBoolean>;
55
+ pending: z.ZodNullable<z.ZodBoolean>;
56
+ }, z.core.$strict>;
57
+ interval: z.ZodLiteral<1>;
58
+ timezone: z.ZodString;
59
+ version: z.ZodLiteral<1>;
60
+ anchor: z.ZodISODateTime;
61
+ origin: z.ZodOptional<z.ZodLiteral<"source-pending">>;
62
+ }, z.core.$strict>, z.ZodObject<{
63
+ calendar: z.ZodObject<{
64
+ boundary: z.ZodEnum<{
65
+ end: "end";
66
+ start: "start";
67
+ }>;
68
+ hour: z.ZodNumber;
69
+ minute: z.ZodNumber;
70
+ second: z.ZodNumber;
71
+ timezone: z.ZodString;
72
+ weekday: z.ZodNumber;
73
+ }, z.core.$strict>;
74
+ frequency: z.ZodLiteral<"legacy-monthly">;
75
+ insufficientOptInsNotice: z.ZodObject<{
76
+ future: z.ZodNullable<z.ZodBoolean>;
77
+ pending: z.ZodNullable<z.ZodBoolean>;
78
+ }, z.core.$strict>;
79
+ interval: z.ZodLiteral<1>;
80
+ timezone: z.ZodString;
81
+ version: z.ZodLiteral<1>;
82
+ anchor: z.ZodNullable<z.ZodISODateTime>;
83
+ origin: z.ZodLiteral<"operator-future-start">;
84
+ }, z.core.$strict>]>;
85
+ export declare const scheduleSchema: z.ZodUnion<readonly [z.ZodObject<{
86
+ anchor: z.ZodString;
87
+ frequency: z.ZodEnum<{
88
+ days: "days";
89
+ hours: "hours";
90
+ minutes: "minutes";
91
+ "monthly-date": "monthly-date";
92
+ "monthly-last-weekday": "monthly-last-weekday";
93
+ "monthly-weekday": "monthly-weekday";
94
+ weeks: "weeks";
95
+ }>;
96
+ interval: z.ZodNumber;
97
+ timezone: z.ZodString;
98
+ }, z.core.$strip>, z.ZodUnion<readonly [z.ZodObject<{
99
+ calendar: z.ZodObject<{
100
+ boundary: z.ZodEnum<{
101
+ end: "end";
102
+ start: "start";
103
+ }>;
104
+ hour: z.ZodNumber;
105
+ minute: z.ZodNumber;
106
+ second: z.ZodNumber;
107
+ timezone: z.ZodString;
108
+ weekday: z.ZodNumber;
109
+ }, z.core.$strict>;
110
+ frequency: z.ZodLiteral<"legacy-monthly">;
111
+ insufficientOptInsNotice: z.ZodObject<{
112
+ future: z.ZodNullable<z.ZodBoolean>;
113
+ pending: z.ZodNullable<z.ZodBoolean>;
114
+ }, z.core.$strict>;
115
+ interval: z.ZodLiteral<1>;
116
+ timezone: z.ZodString;
117
+ version: z.ZodLiteral<1>;
118
+ anchor: z.ZodISODateTime;
119
+ origin: z.ZodOptional<z.ZodLiteral<"source-pending">>;
120
+ }, z.core.$strict>, z.ZodObject<{
121
+ calendar: z.ZodObject<{
122
+ boundary: z.ZodEnum<{
123
+ end: "end";
124
+ start: "start";
125
+ }>;
126
+ hour: z.ZodNumber;
127
+ minute: z.ZodNumber;
128
+ second: z.ZodNumber;
129
+ timezone: z.ZodString;
130
+ weekday: z.ZodNumber;
131
+ }, z.core.$strict>;
132
+ frequency: z.ZodLiteral<"legacy-monthly">;
133
+ insufficientOptInsNotice: z.ZodObject<{
134
+ future: z.ZodNullable<z.ZodBoolean>;
135
+ pending: z.ZodNullable<z.ZodBoolean>;
136
+ }, z.core.$strict>;
137
+ interval: z.ZodLiteral<1>;
138
+ timezone: z.ZodString;
139
+ version: z.ZodLiteral<1>;
140
+ anchor: z.ZodNullable<z.ZodISODateTime>;
141
+ origin: z.ZodLiteral<"operator-future-start">;
142
+ }, z.core.$strict>]>]>;
29
143
  export declare const keepApartRelationshipSchema: z.ZodEnum<{
30
144
  department: "department";
31
145
  "know-each-other": "know-each-other";
@@ -52,6 +166,7 @@ export declare const fineTuneSchema: z.ZodObject<{
52
166
  fof: "fof";
53
167
  }>;
54
168
  }, z.core.$strict>;
169
+ export declare const maximumMatchingIterations = 1000;
55
170
  export declare const matchSchema: z.ZodObject<{
56
171
  fineTune: z.ZodOptional<z.ZodObject<{
57
172
  commonGround: z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>]>;
@@ -98,6 +213,7 @@ export declare const programInput: z.ZodObject<{
98
213
  channelId: z.ZodString;
99
214
  channelName: z.ZodString;
100
215
  includeBios: z.ZodBoolean;
216
+ includeSchedulingLink: z.ZodOptional<z.ZodBoolean>;
101
217
  intro: z.ZodString;
102
218
  match: z.ZodObject<{
103
219
  fineTune: z.ZodOptional<z.ZodObject<{
@@ -142,7 +258,7 @@ export declare const programInput: z.ZodObject<{
142
258
  }>>;
143
259
  }, z.core.$strict>;
144
260
  name: z.ZodString;
145
- schedule: z.ZodObject<{
261
+ schedule: z.ZodUnion<readonly [z.ZodObject<{
146
262
  anchor: z.ZodString;
147
263
  frequency: z.ZodEnum<{
148
264
  days: "days";
@@ -155,7 +271,51 @@ export declare const programInput: z.ZodObject<{
155
271
  }>;
156
272
  interval: z.ZodNumber;
157
273
  timezone: z.ZodString;
158
- }, z.core.$strip>;
274
+ }, z.core.$strip>, z.ZodUnion<readonly [z.ZodObject<{
275
+ calendar: z.ZodObject<{
276
+ boundary: z.ZodEnum<{
277
+ end: "end";
278
+ start: "start";
279
+ }>;
280
+ hour: z.ZodNumber;
281
+ minute: z.ZodNumber;
282
+ second: z.ZodNumber;
283
+ timezone: z.ZodString;
284
+ weekday: z.ZodNumber;
285
+ }, z.core.$strict>;
286
+ frequency: z.ZodLiteral<"legacy-monthly">;
287
+ insufficientOptInsNotice: z.ZodObject<{
288
+ future: z.ZodNullable<z.ZodBoolean>;
289
+ pending: z.ZodNullable<z.ZodBoolean>;
290
+ }, z.core.$strict>;
291
+ interval: z.ZodLiteral<1>;
292
+ timezone: z.ZodString;
293
+ version: z.ZodLiteral<1>;
294
+ anchor: z.ZodISODateTime;
295
+ origin: z.ZodOptional<z.ZodLiteral<"source-pending">>;
296
+ }, z.core.$strict>, z.ZodObject<{
297
+ calendar: z.ZodObject<{
298
+ boundary: z.ZodEnum<{
299
+ end: "end";
300
+ start: "start";
301
+ }>;
302
+ hour: z.ZodNumber;
303
+ minute: z.ZodNumber;
304
+ second: z.ZodNumber;
305
+ timezone: z.ZodString;
306
+ weekday: z.ZodNumber;
307
+ }, z.core.$strict>;
308
+ frequency: z.ZodLiteral<"legacy-monthly">;
309
+ insufficientOptInsNotice: z.ZodObject<{
310
+ future: z.ZodNullable<z.ZodBoolean>;
311
+ pending: z.ZodNullable<z.ZodBoolean>;
312
+ }, z.core.$strict>;
313
+ interval: z.ZodLiteral<1>;
314
+ timezone: z.ZodString;
315
+ version: z.ZodLiteral<1>;
316
+ anchor: z.ZodNullable<z.ZodISODateTime>;
317
+ origin: z.ZodLiteral<"operator-future-start">;
318
+ }, z.core.$strict>]>]>;
159
319
  }, z.core.$strip>;
160
320
  export declare const profileSchema: z.ZodObject<{
161
321
  asks: z.ZodOptional<z.ZodString>;
@@ -171,6 +331,7 @@ export declare const programPatch: z.ZodObject<{
171
331
  channelId: z.ZodOptional<z.ZodString>;
172
332
  channelName: z.ZodOptional<z.ZodString>;
173
333
  includeBios: z.ZodOptional<z.ZodBoolean>;
334
+ includeSchedulingLink: z.ZodOptional<z.ZodBoolean>;
174
335
  intro: z.ZodOptional<z.ZodString>;
175
336
  match: z.ZodOptional<z.ZodObject<{
176
337
  fineTune: z.ZodOptional<z.ZodNullable<z.ZodObject<{
package/dist/schemas.js CHANGED
@@ -14,7 +14,7 @@ export const strategySchema = z.enum([
14
14
  'unit-weighted-multiple-hosts-per-group',
15
15
  'unit-lottery-weighted',
16
16
  ]);
17
- export const scheduleSchema = z
17
+ export const nativeScheduleSchema = z
18
18
  .object({
19
19
  anchor: z.string().max(100),
20
20
  frequency: z.enum([
@@ -40,6 +40,61 @@ export const scheduleSchema = z
40
40
  });
41
41
  }
42
42
  });
43
+ export const legacyMonthlyCalendarSchema = z
44
+ .strictObject({
45
+ boundary: z.enum(['start', 'end']),
46
+ hour: z.number().int().min(0).max(527),
47
+ minute: z.number().int().min(0).max(59),
48
+ second: z.number().int().min(0).max(59),
49
+ timezone: z.string().min(1).max(100),
50
+ weekday: z.number().int().min(0).max(6),
51
+ })
52
+ .superRefine((rule, ctx) => {
53
+ try {
54
+ if (rule.boundary === 'end' && rule.hour > 23)
55
+ throw new Error();
56
+ if (/^[+-]/.test(rule.timezone))
57
+ throw new Error();
58
+ Temporal.Instant.fromEpochMilliseconds(0).toZonedDateTimeISO(rule.timezone);
59
+ }
60
+ catch {
61
+ ctx.addIssue({
62
+ code: 'custom',
63
+ message: 'Invalid preserved monthly calendar',
64
+ });
65
+ }
66
+ });
67
+ const monthlyScheduleFields = {
68
+ calendar: legacyMonthlyCalendarSchema,
69
+ frequency: z.literal('legacy-monthly'),
70
+ insufficientOptInsNotice: z.strictObject({
71
+ future: z.boolean().nullable(),
72
+ pending: z.boolean().nullable(),
73
+ }),
74
+ interval: z.literal(1),
75
+ timezone: z.string().min(1).max(100),
76
+ version: z.literal(1),
77
+ };
78
+ export const legacyMonthlyScheduleSchema = z
79
+ .union([
80
+ z.strictObject({
81
+ ...monthlyScheduleFields,
82
+ anchor: z.iso.datetime({ offset: true }),
83
+ origin: z.literal('source-pending').optional(),
84
+ }),
85
+ z.strictObject({
86
+ ...monthlyScheduleFields,
87
+ anchor: z.iso.datetime({ offset: true }).nullable(),
88
+ origin: z.literal('operator-future-start'),
89
+ }),
90
+ ])
91
+ .refine((rule) => rule.timezone === rule.calendar.timezone, 'Monthly timezone differs');
92
+ // Parsing a persisted rule does not authorize creation; only the bound importer
93
+ // can create legacy schedules. Ordinary writes may retain an existing rule.
94
+ export const scheduleSchema = z.union([
95
+ nativeScheduleSchema,
96
+ legacyMonthlyScheduleSchema,
97
+ ]);
43
98
  export const keepApartRelationshipSchema = z.enum([
44
99
  'reporting-line',
45
100
  'team',
@@ -73,17 +128,19 @@ export const fineTuneSchema = z.strictObject({
73
128
  .enum(['any', 'fof', 'far'])
74
129
  .describe('any: anyone new. fof: friends of friends first (people in common). far: far corners first (nobody in common).'),
75
130
  });
131
+ export const maximumMatchingIterations = 1000;
76
132
  export const matchSchema = z
77
133
  .strictObject({
78
134
  fineTune: fineTuneSchema.optional(),
79
135
  groupSize: z.number().int().min(2).max(8),
80
- iterations: z.number().int().min(1).max(50),
136
+ iterations: z.number().int().min(1).max(maximumMatchingIterations),
81
137
  keepApart: keepApartSchema.optional(),
82
138
  maxGroupSize: z.number().int().min(2).max(8),
83
139
  strategy: strategySchema,
84
140
  unitIds: z.array(z.string().trim().min(1).max(200)).max(20).optional(),
85
141
  unitKind: z.enum(['department', 'team']).optional(),
86
142
  })
143
+ .refine((x) => !currentStrategies.includes(x.strategy) || x.iterations <= 50, 'Purpose matching supports at most 50 search attempts')
87
144
  .refine((x) => x.maxGroupSize >= x.groupSize, 'Maximum group size must be at least the target size')
88
145
  .refine((x) => (!x.strategy.startsWith('unit-') && x.strategy !== 'hosted') ||
89
146
  !!x.unitIds?.length, 'Choose at least one team or department')
@@ -102,6 +159,8 @@ export const programInput = z.object({
102
159
  channelId: z.string().regex(/^[CG][A-Z0-9]+$/),
103
160
  channelName: z.string().trim().min(1).max(100),
104
161
  includeBios: z.boolean(),
162
+ // Omission preserves an existing program's choice; new programs default on.
163
+ includeSchedulingLink: z.boolean().optional(),
105
164
  intro: z.string().trim().min(1).max(2000),
106
165
  match: matchSchema,
107
166
  name: z.string().trim().min(1).max(120),
@@ -125,12 +184,18 @@ export const programPatch = z.strictObject({
125
184
  channelId: programInput.shape.channelId.optional(),
126
185
  channelName: programInput.shape.channelName.optional(),
127
186
  includeBios: z.boolean().optional(),
187
+ includeSchedulingLink: z.boolean().optional(),
128
188
  intro: programInput.shape.intro.optional(),
129
189
  match: z
130
190
  .strictObject({
131
191
  fineTune: fineTuneSchema.partial().nullable().optional(),
132
192
  groupSize: z.number().int().min(2).max(8).optional(),
133
- iterations: z.number().int().min(1).max(50).optional(),
193
+ iterations: z
194
+ .number()
195
+ .int()
196
+ .min(1)
197
+ .max(maximumMatchingIterations)
198
+ .optional(),
134
199
  keepApart: keepApartSchema.nullable().optional(),
135
200
  maxGroupSize: z.number().int().min(2).max(8).optional(),
136
201
  strategy: strategySchema.optional(),