@tiledesk/tiledesk-server 2.19.12 → 2.19.14

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.
@@ -0,0 +1,353 @@
1
+ const crypto = require('crypto');
2
+
3
+ const COLUMN_TYPES = ['string', 'number', 'boolean', 'datetime'];
4
+ const MATCH_MODES = ['all', 'any'];
5
+ const FORBIDDEN_NAMES = ['__proto__', 'constructor', 'prototype', '$where'];
6
+
7
+ const OPERATORS = {
8
+ equal: '$eq',
9
+ not_equal: '$ne',
10
+ greater_than: '$gt',
11
+ greater_or_equal: '$gte',
12
+ less_than: '$lt',
13
+ less_or_equal: '$lte',
14
+ };
15
+
16
+ const STRING_OPERATORS = ['contains', 'starts_with', 'ends_with'];
17
+ const NO_VALUE_OPERATORS = ['exists', 'not_exists'];
18
+
19
+ function generateColumnId() {
20
+ return 'col_' + crypto.randomBytes(8).toString('hex');
21
+ }
22
+
23
+ const COLUMN_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
24
+
25
+ function isValidColumnName(name) {
26
+ if (typeof name !== 'string' || name.length === 0) return false;
27
+ if (name !== name.trim()) return false;
28
+ if (!COLUMN_NAME_REGEX.test(name)) return false;
29
+ if (FORBIDDEN_NAMES.indexOf(name) !== -1) return false;
30
+ return true;
31
+ }
32
+
33
+ function assertValidColumnName(name, context) {
34
+ if (!isValidColumnName(name)) {
35
+ throw new Error(
36
+ (context ? context + ': ' : '') +
37
+ 'Column name must contain only letters, numbers and underscore, with no spaces'
38
+ );
39
+ }
40
+ }
41
+
42
+ function normalizeSchemaForCreate(input) {
43
+ if (input === undefined || input === null) return [];
44
+ if (!Array.isArray(input)) throw new Error('schema must be an array');
45
+
46
+ let schema = [];
47
+ let names = {};
48
+
49
+ for (let i = 0; i < input.length; i++) {
50
+ const col = input[i];
51
+ if (!col || typeof col !== 'object') throw new Error('Invalid column at index ' + i);
52
+ if (col.id) throw new Error('Column id must not be provided at index ' + i + '; ids are generated by the server');
53
+
54
+ const name = typeof col.name === 'string' ? col.name.trim() : '';
55
+ assertValidColumnName(name, 'Invalid column at index ' + i);
56
+ if (!col.type || COLUMN_TYPES.indexOf(col.type) === -1) {
57
+ throw new Error('Column type is required at index ' + i);
58
+ }
59
+ if (names[name]) throw new Error('Column name already exists: ' + name);
60
+ names[name] = true;
61
+
62
+ schema.push({
63
+ id: generateColumnId(),
64
+ name: name,
65
+ type: col.type,
66
+ index: typeof col.index === 'number' ? col.index : i,
67
+ });
68
+ }
69
+ return schema;
70
+ }
71
+
72
+ /**
73
+ * Confronta schema attuale e nuovo (PUT tabella).
74
+ * - colonne senza id: aggiunta (solo schema)
75
+ * - colonne con id e nome diverso: rename righe
76
+ * - id assenti nel nuovo schema: delete campo dalle righe
77
+ */
78
+ function resolveSchemaUpdate(incoming, currentSchema) {
79
+ if (!Array.isArray(incoming)) throw new Error('schema must be an array');
80
+
81
+ const currentById = {};
82
+ for (let i = 0; i < currentSchema.length; i++) {
83
+ currentById[currentSchema[i].id] = currentSchema[i];
84
+ }
85
+
86
+ const newSchema = [];
87
+ const names = {};
88
+ const renames = [];
89
+ const remainingIds = Object.assign({}, currentById);
90
+
91
+ for (let i = 0; i < incoming.length; i++) {
92
+ const col = incoming[i];
93
+ if (!col || typeof col !== 'object') throw new Error('Invalid column at index ' + i);
94
+
95
+ if (col.id && currentById[col.id]) {
96
+ const prev = currentById[col.id];
97
+ delete remainingIds[col.id];
98
+
99
+ const name = typeof col.name === 'string' ? col.name.trim() : '';
100
+ assertValidColumnName(name, 'Invalid column at index ' + i);
101
+
102
+ const type = col.type || prev.type;
103
+ if (!type || COLUMN_TYPES.indexOf(type) === -1) {
104
+ throw new Error('Column type is required at index ' + i);
105
+ }
106
+ if (names[name]) throw new Error('Column name already exists: ' + name);
107
+ names[name] = true;
108
+
109
+ if (name !== prev.name) {
110
+ renames.push({ oldName: prev.name, newName: name });
111
+ }
112
+
113
+ newSchema.push({
114
+ id: col.id,
115
+ name: name,
116
+ type: type,
117
+ index: typeof col.index === 'number' ? col.index : i,
118
+ });
119
+ } else if (!col.id) {
120
+ const added = normalizeColumnToAdd(col, i, newSchema);
121
+ if (names[added.name]) throw new Error('Column name already exists: ' + added.name);
122
+ names[added.name] = true;
123
+ newSchema.push(added);
124
+ } else {
125
+ throw new Error('Column not found: ' + col.id);
126
+ }
127
+ }
128
+
129
+ const deletes = [];
130
+ for (const id in remainingIds) {
131
+ deletes.push(remainingIds[id].name);
132
+ }
133
+
134
+ return { schema: newSchema, renames: renames, deletes: deletes };
135
+ }
136
+
137
+ function normalizeColumnToAdd(col, nextIndex, existingSchema) {
138
+ if (!col || typeof col !== 'object') throw new Error('Invalid column');
139
+ if (col.id) throw new Error('Column id must not be provided; ids are generated by the server');
140
+
141
+ const name = typeof col.name === 'string' ? col.name.trim() : '';
142
+ assertValidColumnName(name);
143
+ if (!col.type || COLUMN_TYPES.indexOf(col.type) === -1) throw new Error('Column type is required');
144
+
145
+ for (let i = 0; i < existingSchema.length; i++) {
146
+ if (existingSchema[i].name === name) throw new Error('Column name already exists: ' + name);
147
+ }
148
+
149
+ return {
150
+ id: generateColumnId(),
151
+ name: name,
152
+ type: col.type,
153
+ index: typeof col.index === 'number' ? col.index : nextIndex,
154
+ };
155
+ }
156
+
157
+ function findColumn(schema, columnId) {
158
+ for (let i = 0; i < schema.length; i++) {
159
+ if (schema[i].id === columnId) return schema[i];
160
+ }
161
+ return null;
162
+ }
163
+
164
+ function findColumnByName(schema, name) {
165
+ for (let i = 0; i < schema.length; i++) {
166
+ if (schema[i].name === name) return schema[i];
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function coerceValue(value, column) {
172
+ if (value === null || value === undefined) return value;
173
+
174
+ switch (column.type) {
175
+ case 'string':
176
+ if (typeof value !== 'string') throw new Error('Invalid value for column ' + column.name + ': expected string');
177
+ return value;
178
+
179
+ case 'number':
180
+ if (typeof value === 'number' && !isNaN(value)) return value;
181
+ if (typeof value === 'string' && value.trim() !== '') {
182
+ const num = Number(value);
183
+ if (!isNaN(num)) return num;
184
+ }
185
+ throw new Error('Invalid value for column ' + column.name + ': expected number');
186
+
187
+ case 'boolean':
188
+ if (typeof value === 'boolean') return value;
189
+ if (typeof value === 'string') {
190
+ const lower = value.trim().toLowerCase();
191
+ if (lower === 'true') return true;
192
+ if (lower === 'false') return false;
193
+ }
194
+ throw new Error('Invalid value for column ' + column.name + ': expected boolean');
195
+
196
+ case 'datetime':
197
+ if (value instanceof Date && !isNaN(value.getTime())) return value;
198
+ if (typeof value === 'string' && value.length > 0) {
199
+ const date = new Date(value);
200
+ if (!isNaN(date.getTime())) return date;
201
+ }
202
+ throw new Error('Invalid value for column ' + column.name + ': expected datetime');
203
+
204
+ default:
205
+ throw new Error('Unsupported column type: ' + column.type);
206
+ }
207
+ }
208
+
209
+ function validateRowData(data, schema) {
210
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
211
+ throw new Error('data must be an object');
212
+ }
213
+
214
+ const allowed = {};
215
+ for (let i = 0; i < schema.length; i++) allowed[schema[i].name] = schema[i];
216
+
217
+ for (const key in data) {
218
+ if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
219
+ if (!allowed[key]) throw new Error('Column ' + key + ' does not exist');
220
+ }
221
+
222
+ const result = {};
223
+ for (let j = 0; j < schema.length; j++) {
224
+ const col = schema[j];
225
+ if (!Object.prototype.hasOwnProperty.call(data, col.name)) continue;
226
+ const raw = data[col.name];
227
+ if (raw === null || raw === undefined) continue;
228
+ result[col.name] = coerceValue(raw, col);
229
+ }
230
+ return result;
231
+ }
232
+
233
+ function escapeRegex(str) {
234
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
235
+ }
236
+
237
+ function buildSearchFilter(searchBody, schema) {
238
+ if (searchBody.rowId) {
239
+ return { _id: searchBody.rowId };
240
+ }
241
+
242
+ if (!searchBody.conditions || !searchBody.conditions.length) {
243
+ throw new Error('rowId or conditions is required');
244
+ }
245
+
246
+ const match = searchBody.must_match || searchBody.match || 'all';
247
+ if (MATCH_MODES.indexOf(match) === -1) throw new Error('must_match must be "all" or "any"');
248
+
249
+ const clauses = [];
250
+
251
+ for (let i = 0; i < searchBody.conditions.length; i++) {
252
+ const item = searchBody.conditions[i];
253
+ if (!item || typeof item !== 'object') throw new Error('Invalid condition at index ' + i);
254
+
255
+ const columnName = typeof item.column === 'string' ? item.column.trim() : '';
256
+ const colMeta = findColumnByName(schema, columnName);
257
+ if (!colMeta) throw new Error('Column ' + item.column + ' does not exist');
258
+
259
+ const operator = item.operator;
260
+ const field = 'data.' + colMeta.name;
261
+
262
+ if (NO_VALUE_OPERATORS.indexOf(operator) !== -1) {
263
+ if (operator === 'exists') {
264
+ clauses.push({ [field]: { $exists: true, $ne: null } });
265
+ } else {
266
+ clauses.push({ $or: [{ [field]: { $exists: false } }, { [field]: null }] });
267
+ }
268
+ continue;
269
+ }
270
+
271
+ if (item.value === undefined) throw new Error('Missing value for condition at index ' + i);
272
+
273
+ const value = coerceValue(item.value, colMeta);
274
+
275
+ if (operator === 'equal') {
276
+ clauses.push({ [field]: value });
277
+ } else if (operator === 'not_equal') {
278
+ clauses.push({ [field]: { $ne: value } });
279
+ } else if (STRING_OPERATORS.indexOf(operator) !== -1) {
280
+ if (colMeta.type !== 'string') throw new Error('Operator ' + operator + ' is only supported for string columns');
281
+ const escaped = escapeRegex(value);
282
+ if (operator === 'contains') {
283
+ clauses.push({ [field]: { $regex: escaped, $options: 'i' } });
284
+ } else if (operator === 'starts_with') {
285
+ clauses.push({ [field]: { $regex: '^' + escaped, $options: 'i' } });
286
+ } else {
287
+ clauses.push({ [field]: { $regex: escaped + '$', $options: 'i' } });
288
+ }
289
+ } else if (OPERATORS[operator]) {
290
+ if (colMeta.type === 'string') throw new Error('Operator ' + operator + ' is not supported for string columns');
291
+ clauses.push({ [field]: { [OPERATORS[operator]]: value } });
292
+ } else {
293
+ throw new Error('Invalid operator: ' + operator);
294
+ }
295
+ }
296
+
297
+ if (match === 'any') return { $or: clauses };
298
+ return { $and: clauses };
299
+ }
300
+
301
+ /** Riempie ogni colonna dello schema; i campi assenti nella riga diventano null. */
302
+ function fillRowDataFromSchema(data, schema) {
303
+ data = data || {};
304
+ const result = {};
305
+ for (let i = 0; i < schema.length; i++) {
306
+ const name = schema[i].name;
307
+ result[name] = Object.prototype.hasOwnProperty.call(data, name) ? data[name] : null;
308
+ }
309
+ return result;
310
+ }
311
+
312
+ function buildRowQueryFilter(body, schema) {
313
+ if (body.id_row) {
314
+ return { _id: body.id_row };
315
+ }
316
+ if (body.conditions && body.conditions.length) {
317
+ return buildSearchFilter({
318
+ conditions: body.conditions,
319
+ must_match: body.must_match,
320
+ match: body.match,
321
+ }, schema);
322
+ }
323
+ throw new Error('id_row or conditions is required');
324
+ }
325
+
326
+ function buildUpdateSet(data, schema) {
327
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
328
+ throw new Error('data is required');
329
+ }
330
+ const validated = validateRowData(data, schema);
331
+ const $set = {};
332
+ for (const key in validated) {
333
+ if (Object.prototype.hasOwnProperty.call(validated, key)) {
334
+ $set['data.' + key] = validated[key];
335
+ }
336
+ }
337
+ return { $set: $set };
338
+ }
339
+
340
+ module.exports = {
341
+ COLUMN_TYPES: COLUMN_TYPES,
342
+ generateColumnId: generateColumnId,
343
+ normalizeSchemaForCreate: normalizeSchemaForCreate,
344
+ normalizeColumnToAdd: normalizeColumnToAdd,
345
+ resolveSchemaUpdate: resolveSchemaUpdate,
346
+ findColumn: findColumn,
347
+ findColumnByName: findColumnByName,
348
+ validateRowData: validateRowData,
349
+ buildSearchFilter: buildSearchFilter,
350
+ buildRowQueryFilter: buildRowQueryFilter,
351
+ buildUpdateSet: buildUpdateSet,
352
+ fillRowDataFromSchema: fillRowDataFromSchema,
353
+ };
@@ -0,0 +1,325 @@
1
+ 'use strict';
2
+
3
+ const Subscription = require('../models/subscription');
4
+ const Faq_kb = require('../models/faq_kb');
5
+
6
+ function systemActor() {
7
+ return { type: 'system', id: 'system', name: 'System' };
8
+ }
9
+
10
+ function userDisplayName(user) {
11
+ if (!user) {
12
+ return undefined;
13
+ }
14
+
15
+ const fullName = user.fullName || user.fullname;
16
+ if (fullName && String(fullName).trim()) {
17
+ return String(fullName).trim();
18
+ }
19
+
20
+ const name = ((user.firstname || '') + ' ' + (user.lastname || '')).trim();
21
+ if (name) {
22
+ return name;
23
+ }
24
+
25
+ return undefined;
26
+ }
27
+
28
+ function resolveUserId(userRef) {
29
+ if (userRef === undefined || userRef === null) {
30
+ return '';
31
+ }
32
+
33
+ if (typeof userRef === 'string' || typeof userRef === 'number') {
34
+ return String(userRef);
35
+ }
36
+
37
+ if (userRef._id) {
38
+ return String(userRef._id);
39
+ }
40
+
41
+ if (userRef.id) {
42
+ return String(userRef.id);
43
+ }
44
+
45
+ return String(userRef);
46
+ }
47
+
48
+ function isBotActor(user) {
49
+ if (!user) {
50
+ return false;
51
+ }
52
+
53
+ if (user instanceof Faq_kb) {
54
+ return true;
55
+ }
56
+
57
+ if (user.constructor && user.constructor.modelName === 'faq_kb') {
58
+ return true;
59
+ }
60
+
61
+ if (user.sub === 'bot' || (typeof user.sub === 'string' && user.sub.endsWith('/bot'))) {
62
+ return true;
63
+ }
64
+
65
+ if (typeof user.aud === 'string' && user.aud.indexOf('/bots/') > -1) {
66
+ return true;
67
+ }
68
+
69
+ if (user.type === 'tilebot' || user.subtype === 'chatbot') {
70
+ return true;
71
+ }
72
+
73
+ // Cached/plain faq_kb payloads may miss type/subtype but still carry bot identity fields.
74
+ if (
75
+ user.slug &&
76
+ user.name &&
77
+ user.id_project &&
78
+ user.createdBy &&
79
+ !user.email &&
80
+ !user.firstname &&
81
+ !user.lastname
82
+ ) {
83
+ return true;
84
+ }
85
+
86
+ return false;
87
+ }
88
+
89
+ function botDisplayName(user) {
90
+ if (!user || !user.name) {
91
+ return undefined;
92
+ }
93
+ const name = String(user.name).trim();
94
+ return name || undefined;
95
+ }
96
+
97
+ function actorFromPrincipal(user) {
98
+ if (!user) {
99
+ return systemActor();
100
+ }
101
+
102
+ if (isSubscriptionActor(user)) {
103
+ return systemActor();
104
+ }
105
+
106
+ if (isBotActor(user)) {
107
+ const id = resolveUserId(user);
108
+ return {
109
+ type: 'bot',
110
+ id: id,
111
+ name: botDisplayName(user) || id
112
+ };
113
+ }
114
+
115
+ return {
116
+ type: 'user',
117
+ id: resolveUserId(user),
118
+ name: user.fullName || user.fullname || userDisplayName(user) || undefined
119
+ };
120
+ }
121
+
122
+ function isSubscriptionActor(user) {
123
+ if (!user) {
124
+ return false;
125
+ }
126
+
127
+ if (user instanceof Subscription) {
128
+ return true;
129
+ }
130
+
131
+ if (user.constructor && user.constructor.modelName === 'subscription') {
132
+ return true;
133
+ }
134
+
135
+ // Webhook subscription document (no user profile fields)
136
+ return Boolean(
137
+ user.event &&
138
+ user.target &&
139
+ user.id_project &&
140
+ user.createdBy &&
141
+ !user.email &&
142
+ !user.firstname &&
143
+ !user.lastname
144
+ );
145
+ }
146
+
147
+ function isSystemAvailabilityInitiator(req) {
148
+ if (!req || !req.body) {
149
+ return false;
150
+ }
151
+
152
+ return req.body.availabilityInitiator === 'system';
153
+ }
154
+
155
+ function isAvailabilityUpdate(body) {
156
+ if (!body) {
157
+ return false;
158
+ }
159
+ return body.user_available !== undefined || body.profileStatus !== undefined;
160
+ }
161
+
162
+ function buildProjectUserUpdateContext(req, previousUserAvailable, previousProfileStatus, targetUserId) {
163
+ const context = {
164
+ previousUserAvailable,
165
+ previousProfileStatus,
166
+ source: 'api',
167
+ updateType: 'admin'
168
+ };
169
+
170
+ if (!req || !req.user) {
171
+ context.source = 'system';
172
+ context.updateType = 'system';
173
+ return context;
174
+ }
175
+
176
+ if (isSubscriptionActor(req.user) || isSystemAvailabilityInitiator(req)) {
177
+ context.source = isSubscriptionActor(req.user) ? 'subscription' : 'api';
178
+ context.updateType = 'system';
179
+ return context;
180
+ }
181
+
182
+ const actorUserId = resolveUserId(req.user);
183
+ const targetId = resolveUserId(targetUserId);
184
+ const isSelfRoute = !req.params || !req.params.project_userid;
185
+
186
+ if (isSelfRoute) {
187
+ context.updateType = 'self';
188
+ return context;
189
+ }
190
+
191
+ if (actorUserId && targetId && actorUserId === targetId) {
192
+ context.updateType = 'self';
193
+ }
194
+
195
+ return context;
196
+ }
197
+
198
+ function actorFromUpdateContext(req, updateContext) {
199
+ if (
200
+ !req ||
201
+ !req.user ||
202
+ (updateContext && updateContext.updateType === 'system') ||
203
+ isSubscriptionActor(req.user) ||
204
+ isSystemAvailabilityInitiator(req)
205
+ ) {
206
+ return systemActor();
207
+ }
208
+
209
+ return {
210
+ type: 'user',
211
+ id: resolveUserId(req.user),
212
+ name: userDisplayName(req.user)
213
+ };
214
+ }
215
+
216
+ function actorFromProjectUserUpdate(event, verb, updateContext) {
217
+ const actor = actorFromUpdateContext(event.req, updateContext);
218
+
219
+ if (actor.name) {
220
+ return actor;
221
+ }
222
+
223
+ const project_user = event.updatedProject_userPopulated;
224
+ const targetUser = project_user && project_user.id_user;
225
+ const targetName = userDisplayName(targetUser);
226
+
227
+ if (
228
+ targetName &&
229
+ (verb === 'PROJECT_USER_AVAILABILITY_SELF' ||
230
+ resolveUserId(targetUser) === actor.id)
231
+ ) {
232
+ return Object.assign({}, actor, { name: targetName });
233
+ }
234
+
235
+ return actor;
236
+ }
237
+
238
+ function verbForProjectUserUpdate(body, updateContext) {
239
+ if (!isAvailabilityUpdate(body)) {
240
+ return 'PROJECT_USER_UPDATE';
241
+ }
242
+
243
+ if (updateContext && updateContext.updateType === 'system') {
244
+ return 'PROJECT_USER_AVAILABILITY_SYSTEM';
245
+ }
246
+
247
+ if (updateContext && updateContext.updateType === 'self') {
248
+ return 'PROJECT_USER_AVAILABILITY_SELF';
249
+ }
250
+
251
+ return 'PROJECT_USER_UPDATE';
252
+ }
253
+
254
+ function availabilityStatusLabel({ user_available, profileStatus }) {
255
+ if (profileStatus !== undefined && profileStatus !== null && profileStatus !== '') {
256
+ return String(profileStatus);
257
+ }
258
+ if (user_available === true) {
259
+ return 'available';
260
+ }
261
+ if (user_available === false) {
262
+ return 'unavailable';
263
+ }
264
+ return 'unknown';
265
+ }
266
+
267
+ function reconcileAvailabilityVerb(event, project_user, verb, updateContext) {
268
+ if (!isAvailabilityUpdate(event.req && event.req.body)) {
269
+ return { verb: verb, updateContext: updateContext, actor: null };
270
+ }
271
+
272
+ const context = Object.assign({}, updateContext);
273
+ let resolvedVerb = verb;
274
+ const targetUserId = resolveUserId(project_user && project_user.id_user);
275
+ const actorUserId = resolveUserId(event.req && event.req.user);
276
+
277
+ if (isSubscriptionActor(event.req && event.req.user) || isSystemAvailabilityInitiator(event.req)) {
278
+ context.updateType = 'system';
279
+ context.source = isSubscriptionActor(event.req.user) ? 'subscription' : (context.source || 'api');
280
+ resolvedVerb = 'PROJECT_USER_AVAILABILITY_SYSTEM';
281
+ return {
282
+ verb: resolvedVerb,
283
+ updateContext: context,
284
+ actor: systemActor()
285
+ };
286
+ }
287
+
288
+ if (resolvedVerb === 'PROJECT_USER_AVAILABILITY_SELF' && targetUserId && actorUserId && actorUserId !== targetUserId) {
289
+ context.updateType = 'admin';
290
+ resolvedVerb = 'PROJECT_USER_UPDATE';
291
+ return { verb: resolvedVerb, updateContext: context, actor: null };
292
+ }
293
+
294
+ return { verb: resolvedVerb, updateContext: context, actor: null };
295
+ }
296
+
297
+ function verbFromAvailabilityUpdateType(updateType) {
298
+ switch (updateType) {
299
+ case 'system':
300
+ return 'PROJECT_USER_AVAILABILITY_SYSTEM';
301
+ case 'self':
302
+ return 'PROJECT_USER_AVAILABILITY_SELF';
303
+ default:
304
+ return 'PROJECT_USER_UPDATE';
305
+ }
306
+ }
307
+
308
+ module.exports = {
309
+ systemActor,
310
+ userDisplayName,
311
+ resolveUserId,
312
+ isBotActor,
313
+ botDisplayName,
314
+ actorFromPrincipal,
315
+ isSubscriptionActor,
316
+ isSystemAvailabilityInitiator,
317
+ isAvailabilityUpdate,
318
+ buildProjectUserUpdateContext,
319
+ actorFromUpdateContext,
320
+ actorFromProjectUserUpdate,
321
+ verbForProjectUserUpdate,
322
+ availabilityStatusLabel,
323
+ reconcileAvailabilityVerb,
324
+ verbFromAvailabilityUpdateType
325
+ };