@01-edu/shared 1.0.2 → 1.0.4

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/attrs-defs.js ADDED
@@ -0,0 +1,3604 @@
1
+ import {
2
+ DAY,
3
+ HOUR,
4
+ MIN,
5
+ WEEK,
6
+ getQuestExtraEndAt,
7
+ getQuestStartAt,
8
+ numTime,
9
+ } from './event-utils.js'
10
+ import { onboardingTypes } from './onboarding.js'
11
+ import { getObjectFromRelativePath } from './path.js'
12
+ import { supportedLang } from './programming-languages.js'
13
+ import { gamesScoring } from './score.js'
14
+ import { hasRequiredSkills, skillsSet } from './skill-definitions.js'
15
+ import {
16
+ arrayOf,
17
+ breath,
18
+ formatedMS,
19
+ getChildByType,
20
+ isNotCamel,
21
+ mapValues,
22
+ toCamelCase,
23
+ } from './toolbox.js'
24
+
25
+ export const MAX_LEVEL = 128
26
+ export const LEVELS = Array(MAX_LEVEL + 1)
27
+
28
+ let i = -1
29
+ let xpIndex = 0
30
+ let cumul = 0
31
+ while (++i < MAX_LEVEL + 1) {
32
+ const req = i * 0.66 + 1
33
+ const base = (i + 2) * 150 + 50
34
+ const total = Math.round(req * base)
35
+ cumul += total
36
+ xpIndex += req
37
+ LEVELS[i] = { level: i, base, cumul, total, xpIndex: Math.floor(xpIndex) }
38
+ }
39
+
40
+ const _children = obj => (obj?.children && Object.values(obj.children)) || []
41
+ const find = (object, by, key) =>
42
+ object && (by(object) ? object : find(object[key], by, key))
43
+ const findParent = (object, by) => find(object, by, 'parent')
44
+ const findPrev = (object, by) => find(object, by, 'prev')
45
+ const hasEvent = ({ event }) => event
46
+ const isQuest = object => object.type === 'quest'
47
+ const isRequired = object => object.attrs.category === 'required'
48
+ const numOrOne = x => (typeof x === 'number' && !Number.isNaN(x) ? x : 1)
49
+ const _difficultyXpCoef = o =>
50
+ o.attrs.difficulty * numOrOne(o.attrs.difficultyMod)
51
+ const add = (a, b) => a + b
52
+ const byValueIdx = ([ak, av], [bk, bv]) => av.index - bv.index
53
+
54
+ const translate = (object, user, key) =>
55
+ object.attrs[`${key}-${user?.attrs?.language}`] ||
56
+ object.attrs[`${key}-en`] ||
57
+ key
58
+
59
+ const firstOfGroup = (a, i, children) => {
60
+ const { group } = a.attrs
61
+ if (!group) return true
62
+ return children.find(b => b.attrs.group === group) === a
63
+ }
64
+
65
+ const getCoreName = object => {
66
+ const corePath = object.attrs.requirements?.core
67
+ const core = _children(object.parent).find(
68
+ child => child.id === getObjectFromRelativePath(corePath, object)?.id,
69
+ )
70
+ return core?.name || ''
71
+ }
72
+
73
+ const getObjectPath = object => {
74
+ const { attrs, name } = object
75
+ if (!attrs.requirements?.core) return name
76
+ // special case only for projects with a core requirement set
77
+ const coreName = getCoreName(object)
78
+ const path = `${coreName}/${getProjectName(object)}`
79
+ return path
80
+ }
81
+
82
+ // Generate default value from the first function and the map of fn.name -> fn
83
+ const Functions = functions => ({
84
+ value: Object.values(functions)[0],
85
+ functions,
86
+ functionsByName: Object.fromEntries(
87
+ Object.values(functions).map(fn => [fn.name, fn]),
88
+ ),
89
+ })
90
+
91
+ const translatable = {
92
+ functions: { translate },
93
+ functionsByName: { translate },
94
+ }
95
+
96
+ // generate attribute type according to its value
97
+ const Literal = (value, options) => ({
98
+ type: typeof value,
99
+ value,
100
+ ...options,
101
+ })
102
+
103
+ // used to generate default value for object
104
+ const getDefaultValue = (typeDef, ...args) => {
105
+ if (!typeDef.required) return
106
+ return typeof typeDef.value === 'function'
107
+ ? typeDef.value(...args)
108
+ : typeDef.value
109
+ }
110
+ // generate default value of objects according to the type described
111
+ const TypeObject = def => ({
112
+ value: (...args) =>
113
+ mapValues(def.type, subDef => getDefaultValue(subDef, ...args)),
114
+ ...def,
115
+ })
116
+
117
+ // ✱ how to declare a new attribute:
118
+ // NB: by front end here, we mean the front end of the admin side of the app
119
+ // attrs.myAttribute = {
120
+ // objectType: {
121
+ // type*: // string, number, boolean, etc. Complex types (objects, array) are described after
122
+ // value*: // defaultValue ONLY IF the attribute is required, or when it is added,
123
+ // functions: // if the attribute is associated to function(s) (if just one - it's this by default; if several, by default the user can choose in between them),
124
+ // label: // text displayed in front end, instead of attribute name (client friendly),
125
+ // instruction: // mention displayed under the label in the front end - to give precision that need to be seen,
126
+ // description: complete explanation of what the attributes does - description is displayed only when the used hover the ℹ️ icon in the front end,
127
+ // required: // if the attribute is always there by default,
128
+ // editable: // if it can be override by schools in the db (=/= choice in between several functions),
129
+ // private: // if the attribute should not be seen in front end,
130
+ // primary: // only used objects in arrays, allow to indicate what key must have a unique value
131
+ // options: // array containing literal possible values
132
+ // acceptDuplicates: // when array type can have duplicates
133
+ // maxElements: // when array has a maximum elements accepted (indicative - has to be handled in the code)
134
+ // check: // function to check if the value of the attribute is valid - used to check refs but also to validate value in the front end when saving
135
+ // },
136
+ // }
137
+ // * properties with the * are absolutely needed
138
+ // By default, required, editable, and private are set to false
139
+ // So it should be added only to set it to true
140
+
141
+ // ✱ Literal(defaultValue, properties) use
142
+ // Can be used to describe simple types. It will generate the type
143
+ // of the attribute from defaultValue (added as value)
144
+ // The argument defaultValue is the default value of the attribute
145
+ // Example: Literal('available', { required: true, ...restOfProperties })
146
+ // will add {type: 'string'} and {value: 'available'} to the properties
147
+
148
+ // ✱ ...Functions({}) use
149
+ // Describe the associated functions of the attribute
150
+ // it generate the `value` (to use only if the default value is a function)
151
+ // and a `functionsByName` property to find matching functions from DB
152
+ // example: {...Functions({'friendly name of the function': myFunction})}
153
+
154
+ // ✱ ...TypeObject({}) use
155
+ // Can be use to describe the type of the attribute if it's a complicated object
156
+ // it will generate the default `value`. It resolves values of each key,
157
+ // only if the key is defined as required
158
+
159
+ // ✱ array and object complex types
160
+ // Complex types can be described directly in `type` property
161
+ // instead of writing 'object' or 'array', the type can be defined in the {} or []
162
+ // For arrays, each item of type object MUST have one of its property
163
+ // tagged with a `primary` key, which value must be unique.
164
+ // That helps the system identify each item when rendering them
165
+ // If several object types of items are defined, then each one of them
166
+ // also needs to have a `type` property of type string with a unique value
167
+
168
+ // ✱ new option: types
169
+ // It is used to define types inside complicated attributes. types are not attributes
170
+ // but can be defined as attributes
171
+ // example: if an attribute is an array of strings, it can be described like this:
172
+ // types.myStringType = { type: 'string', ...restOfTypeProperties }
173
+ // attrs.myArrayOfStrings = { type: [types.myStringType], ...restOfAttrProperties }
174
+
175
+ // list of available attributes and function to validate them
176
+ export const attrs = {}
177
+ export const relationAttrs = {}
178
+ // not exported because all types will be accessed from attributes
179
+ export const types = {}
180
+
181
+ // TODO: add a placeholder to default? propose an address
182
+ // how can we propose an address?
183
+ attrs.address = {
184
+ 'contact-validation-step': Literal('', {
185
+ editable: true,
186
+ required: true,
187
+ label: 'Phone validation URL',
188
+ }),
189
+ }
190
+
191
+ types.allowedFunctions = {
192
+ value: '', // we cannot have an exhaustive list of the fn, as we can restrict from functions to regex
193
+ type: 'string',
194
+ editable: true,
195
+ label: 'Function',
196
+ }
197
+ // not required - we handle the undefined status in the code
198
+ const sharedAllowedFunctions = {
199
+ type: [types.allowedFunctions],
200
+ value: [],
201
+ label: 'Allowed functions',
202
+ instruction: 'Functions authorized to the student',
203
+ editable: true,
204
+ restrictive: true, // could also be constraining or exclusive
205
+ }
206
+ // allowedFunctions unit: array of string
207
+ attrs.allowedFunctions = {
208
+ exercise: sharedAllowedFunctions,
209
+ raid: sharedAllowedFunctions,
210
+ }
211
+
212
+ const autoValidateLabel = 'Automatic success'
213
+ const autoValidateWhereLabel = `${autoValidateLabel} if`
214
+ const autoRejectWhereLabel = `Automatic reject if`
215
+ const sharedAutoValidationOperator = (action, autoValidationLabel) => ({
216
+ type: 'string',
217
+ restrictive: true,
218
+ label: `${action} - number of conditions to fulfil`,
219
+ ...Functions({ all: () => 'and', one: () => 'or' }),
220
+ })
221
+ attrs.autoRejectOperator = {
222
+ games: sharedAutoValidationOperator('Reject', autoRejectWhereLabel),
223
+ }
224
+
225
+ const games = Object.entries(gamesScoring)
226
+ const gamesParameters = games.flatMap(([game, params]) => [
227
+ [
228
+ `${game}Score`,
229
+ {
230
+ shouldFailUnder: params.shouldFailUnderScore,
231
+ shouldSucceedFrom: params.shouldSucceedFromScore,
232
+ },
233
+ ],
234
+ [
235
+ `${game}Level`,
236
+ {
237
+ shouldFailUnder: params.shouldFailUnderLevel,
238
+ shouldSucceedFrom: params.shouldSucceedFromLevel,
239
+ },
240
+ ],
241
+ ])
242
+ const allParameters = [
243
+ ['score', { shouldFailUnder: 25, shouldSucceedFrom: 50 }],
244
+ ['level', { shouldFailUnder: 10, shouldSucceedFrom: 25 }],
245
+ ...gamesParameters,
246
+ ]
247
+ const allParametersStr = allParameters
248
+ .map(([parameter, _]) => breath(parameter))
249
+ .join(', ')
250
+ .slice(0, -2)
251
+
252
+ // TODO: mv to a attrs-utils file
253
+ const isntObjectOrIsEmpty = elem =>
254
+ Array.isArray(elem) || typeof elem !== 'object' || !Object.keys(elem).length
255
+ const checkNotEmpty = (value, _, key) => {
256
+ if (!value.length) {
257
+ const err = Error('Must be a non empty value')
258
+ err.label = key
259
+ throw err
260
+ }
261
+ }
262
+ const checkNumberInBetween = (value, min, max) => {
263
+ if (value < min || value > max) {
264
+ throw Error(`Expected to be a number range ${min} to ${max}`)
265
+ }
266
+ }
267
+ const checkIntegerInBetween = (value, min, max) => {
268
+ if (value < min || value > max || !Number.isInteger(value)) {
269
+ throw Error(`Expected to be an integer range ${min} to ${max}`)
270
+ }
271
+ }
272
+ const checkDurationInBetween = (value, min, max) => {
273
+ if (value < min || value > max) {
274
+ throw Error(
275
+ `Expected to be a duration between ${formatedMS(min)} and ${formatedMS(
276
+ max,
277
+ )}`,
278
+ )
279
+ }
280
+ }
281
+ const checkTextLength = (str, length) => {
282
+ if (str.length > length) {
283
+ throw Error(`Max ${length} characters (spaces included).`)
284
+ }
285
+ }
286
+ const checkValidURL = value => {
287
+ try {
288
+ return Boolean(new URL(value))
289
+ } catch (err) {
290
+ throw Error('Invalid URL.')
291
+ }
292
+ }
293
+ const sharedComparisonIntegerTypes = {
294
+ editable: true,
295
+ options: arrayOf(100, 1),
296
+ check: limit => checkIntegerInBetween(limit, 1, 100),
297
+ }
298
+ // none of these conditions are required
299
+ types.autoRejectWhereConditions = Object.fromEntries(
300
+ allParameters.map(([parameter, { shouldFailUnder }]) => [
301
+ parameter,
302
+ {
303
+ value: {},
304
+ editable: true,
305
+ description: `Condition relative to ${breath(
306
+ parameter,
307
+ )} of the candidate.`,
308
+ check: condition => {
309
+ if (isntObjectOrIsEmpty(condition)) {
310
+ throw Error('expect to contain a comparison.')
311
+ }
312
+ },
313
+ type: {
314
+ '<': Literal(shouldFailUnder, {
315
+ label: 'is lower than',
316
+ description: 'Under this number, condition is fulfilled.',
317
+ ...sharedComparisonIntegerTypes,
318
+ }),
319
+ '<=': Literal(shouldFailUnder, {
320
+ label: 'is lower than or equal',
321
+ description: 'Until this number, condition is fulfilled.',
322
+ ...sharedComparisonIntegerTypes,
323
+ }),
324
+ },
325
+ },
326
+ ]),
327
+ )
328
+ attrs.autoRejectWhere = {
329
+ games: {
330
+ value: {},
331
+ editable: true,
332
+ restrictive: true,
333
+ label: autoRejectWhereLabel,
334
+ instruction: 'List of conditions to automatically reject an application',
335
+ type: types.autoRejectWhereConditions,
336
+ check: autoRejectWhere => {
337
+ if (isntObjectOrIsEmpty(autoRejectWhere)) {
338
+ throw Error('expect to contain at least one condition.')
339
+ }
340
+ },
341
+ },
342
+ }
343
+
344
+ attrs.autoValidate = {
345
+ administration: Literal(false, {
346
+ label: autoValidateLabel,
347
+ instruction: 'Validate all applications when they are submitted',
348
+ restrictive: true,
349
+ editable: true,
350
+ }),
351
+ }
352
+
353
+ attrs.autoValidateOperator = {
354
+ games: sharedAutoValidationOperator('Success', autoValidateWhereLabel),
355
+ }
356
+
357
+ // none of these conditions are required
358
+ types.autoValidateWhereConditions = Object.fromEntries(
359
+ allParameters.map(([parameter, { shouldSucceedFrom }]) => [
360
+ parameter,
361
+ {
362
+ value: {},
363
+ editable: true,
364
+ description: `Condition relative to ${breath(
365
+ parameter,
366
+ )} of the candidate.`,
367
+ check: condition => {
368
+ if (isntObjectOrIsEmpty(condition)) {
369
+ throw Error('expect to contain at least one condition.')
370
+ }
371
+ },
372
+ type: {
373
+ '>': Literal(shouldSucceedFrom, {
374
+ label: 'is bigger than',
375
+ description: 'Above this number, condition is fulfilled.',
376
+ ...sharedComparisonIntegerTypes,
377
+ }),
378
+ '>=': Literal(shouldSucceedFrom, {
379
+ label: 'is bigger than or equal',
380
+ description: 'From this number, condition is fulfilled.',
381
+ ...sharedComparisonIntegerTypes,
382
+ }),
383
+ },
384
+ },
385
+ ]),
386
+ )
387
+ attrs.autoValidateWhere = {
388
+ games: {
389
+ value: {},
390
+ editable: true,
391
+ restrictive: true,
392
+ label: autoValidateWhereLabel,
393
+ instruction: 'List of conditions to automatically accept an application',
394
+ type: types.autoValidateWhereConditions,
395
+ check: autoValidateWhere => {
396
+ if (isntObjectOrIsEmpty(autoValidateWhere)) {
397
+ throw Error('expect to contain at least one condition.')
398
+ }
399
+ },
400
+ },
401
+ }
402
+
403
+ const skillsList = {
404
+ value: {},
405
+ editable: true,
406
+ label: 'Skills',
407
+ type: mapValues(skillsSet, ({ name, type: instruction, description }) => ({
408
+ label: name,
409
+ instruction,
410
+ description,
411
+ editable: true,
412
+ type: 'number',
413
+ value: 1,
414
+ options: arrayOf(100, 1),
415
+ check: amount => checkIntegerInBetween(amount, 1, 100),
416
+ })),
417
+ }
418
+ const sharedBasedSkills = {
419
+ ...skillsList,
420
+ instruction: 'Expertise(s) that this content is worth.',
421
+ }
422
+ relationAttrs.baseSkills = {
423
+ // only if result associated directly to the object
424
+ exam: { exercise: sharedBasedSkills },
425
+ quest: { exercise: sharedBasedSkills },
426
+ module: { piscine: sharedBasedSkills, project: sharedBasedSkills },
427
+ campus: { piscine: sharedBasedSkills },
428
+ piscine: { raid: sharedBasedSkills, project: sharedBasedSkills },
429
+ }
430
+
431
+ const getBaseXpByLevelAndDifficulty = object => {
432
+ if (Object.keys(object.children || {}).length && object.type !== 'raid') {
433
+ return 0 // rm type 'raid' condition after clean alem db
434
+ }
435
+ const level = LEVELS[object.attrs.level] || LEVELS[0]
436
+ return Math.round(level.base * numOrOne(_difficultyXpCoef(object)))
437
+ }
438
+ const sharedBaseXp = {
439
+ type: 'number',
440
+ required: true,
441
+ // TODO: we should not manually set base-xp but play with level and difficulty attribute
442
+ // then this attribute should not be editable
443
+ editable: true,
444
+ label: 'XP',
445
+ instruction: 'Expertise(s) that this content is worth.',
446
+ ...Functions({ 'by level and difficulty': getBaseXpByLevelAndDifficulty }),
447
+ check: amount => {
448
+ if (!Number.isInteger(amount)) throw Error('Must be a whole number')
449
+ if (amount < 0) throw Error('Must not be negative')
450
+ },
451
+ }
452
+ relationAttrs.baseXp = {
453
+ // only if result associated directly to the object
454
+ exam: { exercise: sharedBaseXp },
455
+ quest: { exercise: sharedBaseXp },
456
+ module: { piscine: sharedBaseXp, project: sharedBaseXp },
457
+ piscine: { raid: sharedBaseXp, project: sharedBaseXp },
458
+ // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
459
+ campus: { piscine: sharedBaseXp },
460
+ }
461
+
462
+ attrs.blockStartDelay = {
463
+ exam: Literal(15 * MIN, {
464
+ label: 'Time to join the exam',
465
+ editable: true,
466
+ }),
467
+ }
468
+
469
+ const sharedButtonText = Literal('Save', {
470
+ label: 'Submit button text',
471
+ editable: true,
472
+ ...translatable,
473
+ })
474
+ attrs.buttonText = {
475
+ 'sign-step': sharedButtonText,
476
+ 'form-step': sharedButtonText,
477
+ }
478
+
479
+ // const getGamesObjectsAndCampaigns = graphql(`
480
+ // query toad_campaign_ids($id: Int!) {
481
+ // object(
482
+ // where: {
483
+ // _and: [
484
+ // { attrs: { _has_key: "campaignId" } }
485
+ // { _not: { id: { _eq: $id } } }
486
+ // ]
487
+ // }
488
+ // ) { attrs }
489
+ // toad_campaigns { id }
490
+ // }
491
+ // `)
492
+ attrs.campaignId = {
493
+ games: Literal('', {
494
+ label: 'Games campaign related',
495
+ instruction: 'Id required',
496
+ required: true,
497
+ editable: true,
498
+ restrictive: true,
499
+ // TODO: check if we use the check here to add some security on the campaignId
500
+ // but we would need to do some query (which is not something we planned to do)
501
+ // check: (value, { id }) => {
502
+ // // get all toad campaigns + all campaignIds used in campus
503
+ // const { object, toad_campaigns } = await getGamesObjectsAndCampaigns({
504
+ // id,
505
+ // })
506
+ // // compare with toad_campaigns and check if it's one of them
507
+ // if (!toad_campaigns.some(({ id }) => id === value)) {
508
+ // throw Error('Campaign not found.')
509
+ // }
510
+ // // compare with campaignIds already used and check it's not used in another object
511
+ // if (object.map(({ attrs }) => attrs.campaignId).includes(value)) {
512
+ // throw Error('Must be unique')
513
+ // }
514
+ // },
515
+ }),
516
+ }
517
+
518
+ const getCampus = object => object.path.split('/')[1]
519
+ const sharedCampus = {
520
+ label: 'Campus',
521
+ type: 'string',
522
+ required: true,
523
+ private: true,
524
+ ...Functions({ 'from campus name': getCampus }),
525
+ }
526
+ attrs.campus = {
527
+ exercise: sharedCampus,
528
+ module: sharedCampus,
529
+ piscine: sharedCampus,
530
+ project: sharedCampus,
531
+ raid: sharedCampus,
532
+ exam: sharedCampus,
533
+ quest: sharedCampus,
534
+ ...Object.fromEntries([...onboardingTypes].map(type => [type, sharedCampus])),
535
+ }
536
+
537
+ const sharedCapacity = Literal(400, {
538
+ label: 'Event capacity',
539
+ instruction: 'From 1 to 1000.',
540
+ editable: true,
541
+ required: true,
542
+ check: amount => {
543
+ if (!Number.isInteger(amount)) throw Error('Must be a whole number')
544
+ if (amount > 1000) throw Error('Cannot be more than 1000.')
545
+ if (amount < 1) throw Error('Must be one or more')
546
+ },
547
+ })
548
+ attrs.capacity = {
549
+ exam: sharedCapacity,
550
+ module: sharedCapacity,
551
+ piscine: sharedCapacity,
552
+ raid: sharedCapacity,
553
+ interview: sharedCapacity,
554
+ }
555
+
556
+ const exerciseCategories = new Set(['required', 'optional', 'bonus'])
557
+ const projectCategories = new Set(['synchronous', 'enrichment', 'asynchronous'])
558
+ // to check: should we set dynamically the enrichment project category relying
559
+ // on the fact that the object has a valid core project set in its requirements?
560
+ // if so, in the admin interface we have to enforce to select a core project for the enrichment category to be applied
561
+ const projectCategory = Literal('synchronous', {
562
+ label: 'Category of the project',
563
+ required: true,
564
+ options: ['synchronous', 'asynchronous', 'enrichment'],
565
+ check: value => {
566
+ if (!projectCategories.has(value)) {
567
+ throw Error('Must be "synchronous", "asynchronous" or "enrichment"')
568
+ }
569
+ },
570
+ })
571
+ relationAttrs.category = {
572
+ // exercises in exams need the category for expected xp calculation
573
+ exam: {
574
+ exercise: Literal('required', {
575
+ label: 'Category of the exercise',
576
+ required: true,
577
+ private: true,
578
+ check: value => {
579
+ const exerciseCategoriesInExam = new Set(['required', 'optional'])
580
+ if (!exerciseCategoriesInExam.has(value)) {
581
+ throw Error('Must be "required" or "optional"')
582
+ }
583
+ },
584
+ }),
585
+ },
586
+ quest: {
587
+ exercise: Literal('required', {
588
+ label: 'Category of the exercise',
589
+ required: true,
590
+ editable: true,
591
+ options: ['required', 'optional', 'bonus'],
592
+ check: value => {
593
+ if (!exerciseCategories.has(value)) {
594
+ throw Error('Must be "required", "optional" or "bonus"')
595
+ }
596
+ },
597
+ }),
598
+ },
599
+ module: { project: { ...projectCategory, editable: true } },
600
+ piscine: {
601
+ project: { ...projectCategory, private: true },
602
+ raid: Literal('required', { required: true, private: true }),
603
+ },
604
+ }
605
+
606
+ // checkbox is not required - sign step can be used without, just to display a text
607
+ // for example: a text to ask if a user authorize to share personal info to partners
608
+ attrs.checkbox = {
609
+ 'sign-step': {
610
+ label: 'Display a checkbox',
611
+ instruction: 'Minimum options: "label" and "required"',
612
+ value: {},
613
+ editable: true,
614
+ type: 'object',
615
+ check: value => {
616
+ if (!value.label || typeof value.label !== 'string') {
617
+ throw Error(
618
+ '"label" must be defined as a text to be displayed on the right of the checkbox.',
619
+ )
620
+ }
621
+ // NB: required has to be defined, even if false
622
+ // (to help admins not forget to put it if it is mandatory)
623
+ if (typeof value.required !== 'boolean') {
624
+ throw Error(
625
+ '"required" must be defined as true or false (if the checkbox is mandatory or not).',
626
+ )
627
+ }
628
+ },
629
+ ...translatable,
630
+ },
631
+ }
632
+
633
+ types.codeEditorFile = TypeObject({
634
+ label: 'File',
635
+ editable: true,
636
+ type: {
637
+ giteaPath: Literal('root/public/README.md', {
638
+ label: 'Gitea file path',
639
+ instruction: 'Example: "{user}/{repo}/{path-to-file}"',
640
+ editable: true,
641
+ check: value => {
642
+ checkTextLength(value, 200)
643
+ checkNotEmpty(value)
644
+ },
645
+ }),
646
+ editorPath: Literal('provided/README.md', {
647
+ label: 'Path in Code editor',
648
+ required: true,
649
+ editable: true,
650
+ check: value => {
651
+ checkTextLength(value, 200)
652
+ checkNotEmpty(value)
653
+ },
654
+ }),
655
+ },
656
+ })
657
+
658
+ types.providedFiles = {
659
+ editable: true,
660
+ label: 'Provided files',
661
+ type: [types.codeEditorFile],
662
+ }
663
+ types.enableCodeEditor = (enabled, options) => {
664
+ return Literal(enabled, {
665
+ editable: true,
666
+ required: true,
667
+ label: 'Enable',
668
+ ...options,
669
+ })
670
+ }
671
+
672
+ const sharedCodeEditor = {
673
+ label: 'Code editor settings',
674
+ editable: true,
675
+ // code editor will not be available for shell exercises
676
+ // shell exercises need to commit files that are not supported, for example ".tar"
677
+ check: (_, { attrs }) => {
678
+ if (attrs.language === 'sh') {
679
+ throw Error('The code editor does not support shell exercises')
680
+ }
681
+ },
682
+ type: {
683
+ // If checked, will enable the code editor in the platform for the exercise.
684
+ enabled: types.enableCodeEditor(false),
685
+ providedFiles: types.providedFiles,
686
+ },
687
+ }
688
+
689
+ relationAttrs.codeEditor = {
690
+ quest: {
691
+ exercise: TypeObject({ ...sharedCodeEditor }),
692
+ },
693
+ exam: {
694
+ exercise: TypeObject({
695
+ ...sharedCodeEditor,
696
+ required: true,
697
+ type: {
698
+ enabled: types.enableCodeEditor(true, { editable: false }),
699
+ providedFiles: types.providedFiles,
700
+ },
701
+ }),
702
+ },
703
+ }
704
+
705
+ const checkCRMvalues = value => {
706
+ const reg = /^[^a-z](\d+)|[^a-z0-9_]/g
707
+ const i = value.search(reg)
708
+ if (i !== -1) {
709
+ const wrongCharacter = value[i]
710
+ const suggestion = value.normalize('NFD').toLowerCase().replace(reg, '')
711
+ const suffix = suggestion ? `Did you mean ${suggestion}?` : ''
712
+ throw Error(
713
+ `Property names should start with a letter, only contain lowercase letters, numbers, and underscores (${i}:"${wrongCharacter}"). ${suffix}`,
714
+ )
715
+ }
716
+ }
717
+
718
+ const ENABLE_CRM = process.env.ENABLE_CRM
719
+ const CRM_NAME = process.env.CRM_NAME
720
+ const CRM_ENDPOINT = process.env.CRM_ENDPOINT
721
+ const CRM_PRIVATE_KEY = process.env.CRM_PRIVATE_KEY
722
+
723
+ // types.crmOnRegistration = Literal('event_registration', {
724
+ // editable: true,
725
+ // check: value => {
726
+ // checkNotEmpty(value)
727
+ // checkCRMvalues(value)
728
+ // },
729
+ // label: 'Notify on registration',
730
+ // })
731
+
732
+ types.crmOnValidation = Literal('event_status', {
733
+ editable: true,
734
+ label: 'Validation status',
735
+
736
+ check: value => {
737
+ checkNotEmpty(value)
738
+ checkCRMvalues(value)
739
+ },
740
+ })
741
+
742
+ const sharedCRM = TypeObject({
743
+ label: 'Customer Relationship Management (CRM)',
744
+ editable: true,
745
+ restrictive: true,
746
+ private: !(
747
+ ENABLE_CRM &&
748
+ JSON.parse(ENABLE_CRM) &&
749
+ CRM_NAME &&
750
+ CRM_ENDPOINT &&
751
+ CRM_PRIVATE_KEY
752
+ ),
753
+ type: {
754
+ // TODO : add type for registration process "onRegistration"
755
+ // onRegistration: types.crmOnRegistration,
756
+ onValidation: types.crmOnValidation,
757
+ },
758
+ })
759
+
760
+ types.crmGamesOnAttempts = Literal('number_of_attempts_remaining', {
761
+ label: 'Remaining attempts',
762
+ editable: true,
763
+
764
+ check: value => {
765
+ checkNotEmpty(value)
766
+ checkCRMvalues(value)
767
+ },
768
+ })
769
+
770
+ // RootEvents
771
+ types.crmOnProgress = Literal('last_progress', {
772
+ label: 'Currently in progress',
773
+ editable: true,
774
+
775
+ check: value => {
776
+ checkNotEmpty(value)
777
+ checkCRMvalues(value)
778
+ },
779
+ })
780
+
781
+ types.gamesCRM = {
782
+ // how many attempts remain
783
+ onAttempts: types.crmGamesOnAttempts,
784
+ }
785
+
786
+ types.crmDueDate = Literal('audit_due_date', {
787
+ label: 'Due date',
788
+ editable: true,
789
+
790
+ check: value => {
791
+ checkNotEmpty(value)
792
+ checkCRMvalues(value)
793
+ },
794
+ })
795
+
796
+ const crmRootEvents = {
797
+ ...sharedCRM,
798
+ type: { ...sharedCRM.type, onProgress: types.crmOnProgress },
799
+ }
800
+
801
+ attrs.crm = {
802
+ games: {
803
+ ...sharedCRM,
804
+ type: { ...types.gamesCRM, onValidation: sharedCRM.type.onValidation },
805
+ },
806
+ interview: sharedCRM,
807
+ piscine: crmRootEvents,
808
+ module: crmRootEvents,
809
+ exam: sharedCRM,
810
+ raid: sharedCRM,
811
+ project: {
812
+ ...sharedCRM,
813
+ type: { dueDate: types.crmDueDate },
814
+ },
815
+ }
816
+
817
+ const getDelay = ({ type, parent, index, attrs }) => {
818
+ if (parent && type === 'exercise' && attrs.special) {
819
+ // duration is expressed in days
820
+ const prevExercisesDuration = Object.values(parent.children)
821
+ .filter(ex => ex.index < index)
822
+ .map(({ attrs }) => attrs.duration)
823
+ .reduce(add, 0)
824
+ // now return in millisecond
825
+ return prevExercisesDuration * DAY
826
+ }
827
+ return attrs.startDay ? (attrs.startDay - 1) * DAY : 0
828
+ }
829
+ const sharedDelay = {
830
+ type: 'number',
831
+ required: true,
832
+ private: true,
833
+ }
834
+ relationAttrs.delay = {
835
+ piscine: {
836
+ quest: {
837
+ ...sharedDelay,
838
+ label: 'Time before the quest starts',
839
+ // NB: 'startDay' should be rename if one day it becomes a public attribute
840
+ ...Functions({ 'based on previous content temporal-windows': getDelay }),
841
+ },
842
+ // needed for project to calculate hasStarted, and then status
843
+ // TODO: figure out how tron is handled
844
+ project: {
845
+ ...sharedDelay,
846
+ label: 'Time before the project starts',
847
+ ...Functions({ 'based on previous content temporal-windows': getDelay }),
848
+ },
849
+ },
850
+ // for the hackathon
851
+ quest: {
852
+ exercise: {
853
+ ...sharedDelay,
854
+ label: 'Time before the exercise starts',
855
+ ...Functions({
856
+ 'based on previous exercises temporal-windows': getDelay,
857
+ }),
858
+ },
859
+ },
860
+ // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
861
+ exam: {
862
+ exercise: {
863
+ ...sharedDelay,
864
+ label: 'Time before the exercise starts',
865
+ ...Functions({
866
+ 'based on previous exercises temporal-windows': getDelay,
867
+ }),
868
+ },
869
+ },
870
+ module: { project: Literal(0, sharedDelay) },
871
+ }
872
+
873
+ const exerciseDifficulty = ({ attrs, prev }) => {
874
+ if (attrs.parentType !== 'exam') return 1
875
+ if (attrs.group) return Math.round(attrs.group * 2)
876
+ return Math.round(((prev?.attrs.group || 0) + 1) * 2)
877
+ }
878
+
879
+ const sumOfChildrenDifficulty = object =>
880
+ _children(object)
881
+ .filter(firstOfGroup)
882
+ .filter(isRequired)
883
+ .map(_difficultyXpCoef)
884
+ .reduce(add, 0)
885
+
886
+ // TODO: update with level - still in progress
887
+ const sharedDifficulty = Literal(1, {
888
+ required: true,
889
+ label: 'Difficulty',
890
+ // options: [0.75, 1, 1.25, 1.5, 1.75, 2], // TODO: uncomment when content had fixed the difficulties
891
+ instruction: 'From 0.75 to 2 (in increments of 0.25)',
892
+ // TODO: uncomment when refs are fixed
893
+ // check: value => checkNumberInBetween(value, 0.75, 2),
894
+ })
895
+ const privateDifficulty = {
896
+ ...sharedDifficulty,
897
+ private: true,
898
+ ...Functions({
899
+ 'Sum of required children exercises difficulties': sumOfChildrenDifficulty,
900
+ }),
901
+ }
902
+ relationAttrs.difficulty = {
903
+ module: {
904
+ project: { ...sharedDifficulty, editable: true },
905
+ exam: privateDifficulty,
906
+ },
907
+ piscine: {
908
+ project: { ...sharedDifficulty, editable: true },
909
+ raid: { ...sharedDifficulty, editable: true },
910
+ exam: privateDifficulty,
911
+ quest: privateDifficulty,
912
+ },
913
+ quest: { exercise: Literal(1, { ...sharedDifficulty, editable: true }) },
914
+ exam: {
915
+ exercise: {
916
+ ...sharedDifficulty,
917
+ editable: true,
918
+ editableDefaultValue: 1,
919
+ ...Functions({ 'Based on exercise group or 1': exerciseDifficulty }),
920
+ },
921
+ },
922
+ }
923
+
924
+ // TODO: rename this attribute by 'xpCoefficient' for example
925
+ // but it is in db so it would need to do a migration
926
+ const getDifficultyMod = ({ parent }) => parent?.attrs.difficultyMod
927
+ const sharedDifficultyMod = Literal(1, {
928
+ label: 'XP Coefficient',
929
+ required: true,
930
+ editable: true,
931
+ editableDefaultValue: 1,
932
+ ...Functions({ 'from parent': getDifficultyMod }),
933
+ check: value => checkNumberInBetween(value, 0, 64),
934
+ })
935
+ relationAttrs.difficultyMod = {
936
+ campus: { piscine: sharedDifficultyMod }, // ??
937
+ module: {
938
+ project: sharedDifficultyMod,
939
+ piscine: sharedDifficultyMod,
940
+ exam: sharedDifficultyMod, // ??
941
+ },
942
+ piscine: {
943
+ raid: sharedDifficultyMod,
944
+ exam: sharedDifficultyMod,
945
+ quest: sharedDifficultyMod,
946
+ project: sharedDifficultyMod,
947
+ },
948
+ quest: { exercise: sharedDifficultyMod },
949
+ exam: { exercise: sharedDifficultyMod },
950
+ }
951
+
952
+ const getName = ({ name }) => name
953
+ const getProjectName = object => {
954
+ const { name, attrs } = object
955
+ if (!attrs.requirements?.core) return name
956
+ // only for projects with a core requirement set, and which name starts by the core name:
957
+ // remove the core name from the name to avoid repetition
958
+ const coreName = getCoreName(object)
959
+ return name.startsWith(coreName) ? name.slice(coreName.length + 1) : name
960
+ }
961
+ const sharedDisplayedName = Literal('', {
962
+ editable: true,
963
+ label: 'Displayed name',
964
+ check: title => checkTextLength(title, 60),
965
+ })
966
+ // TODO: mv onboarding name attribute to displayedName
967
+ attrs.displayedName = {
968
+ raid: {
969
+ ...sharedDisplayedName,
970
+ ...Functions({ 'from name': getName }),
971
+ },
972
+ project: {
973
+ ...sharedDisplayedName,
974
+ ...Functions({ 'from name (or light version)': getProjectName }),
975
+ required: true,
976
+ },
977
+ // TODO: maybe rm it here and simply use name
978
+ piscine: sharedDisplayedName,
979
+ exercise: sharedDisplayedName,
980
+ quest: sharedDisplayedName, // not required
981
+ }
982
+
983
+ const isHackathon = object => object.attrs.special
984
+ const getExerciseDuration = ({ parent, attrs }) =>
985
+ isHackathon(parent)
986
+ ? parent.attrs.duration / Object.keys(parent.children).length
987
+ : 0
988
+ const oneDayDuration = () => 1
989
+ const twoDaysDuration = () => 2
990
+ const sharedDuration = Literal(1, {
991
+ label: 'Temporal-window duration in the event',
992
+ required: true,
993
+ editable: true,
994
+ check: duration => checkDurationInBetween(duration * DAY, 0, 50 * DAY), // TODO: maybe max should be another int
995
+ editableDefaultValue: 1,
996
+ ...Functions({ 'one day': oneDayDuration }),
997
+ })
998
+ // duration unit: positive integer
999
+ relationAttrs.duration = {
1000
+ // TODO: rm, here just for campus comparison
1001
+ module: {
1002
+ exam: sharedDuration,
1003
+ },
1004
+ piscine: {
1005
+ quest: sharedDuration,
1006
+ // exam and raid duration are used to calculate start day of the next quests
1007
+ // it is useful only for the temporal-window - it has no impact on their scope/start/end
1008
+ exam: sharedDuration, // used anyway to calculate the temporal-window - 1 by default
1009
+ raid: {
1010
+ ...sharedDuration,
1011
+ ...Functions({ 'two days': twoDaysDuration }),
1012
+ }, // used anyway to calculate the temporal-window - override of 2 in db
1013
+ project: sharedDuration,
1014
+ },
1015
+ quest: {
1016
+ exercise: {
1017
+ ...sharedDuration,
1018
+ ...Functions({
1019
+ // TODO: maybe find a better fn name (too long) -
1020
+ // 'handled if special' could work but is not descriptive
1021
+ 'proportional to the others (in hackathon mode)': getExerciseDuration,
1022
+ }),
1023
+ },
1024
+ },
1025
+ // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
1026
+ exam: {
1027
+ exercise: {
1028
+ ...sharedDuration,
1029
+ ...Functions({
1030
+ // TODO: maybe find a better fn name (too long) -
1031
+ // 'handled if special' could work but is not descriptive
1032
+ 'proportional to the others (in hackathon mode)': getExerciseDuration,
1033
+ }),
1034
+ },
1035
+ },
1036
+ }
1037
+
1038
+ const sharedEventDuration = {
1039
+ label: 'Event duration',
1040
+ required: true,
1041
+ editable: true,
1042
+ check: duration =>
1043
+ checkDurationInBetween(duration * MIN, 1 * MIN, 365 * DAY * 3), // 1min to 3 years (+1 extra day)
1044
+ }
1045
+ attrs.eventDuration = {
1046
+ exam: Literal((4 * HOUR) / MIN, sharedEventDuration),
1047
+ module: Literal((365 * DAY * 2) / MIN, sharedEventDuration),
1048
+ interview: Literal((DAY * 1) / MIN, sharedEventDuration),
1049
+ piscine: Literal((4 * WEEK) / MIN, sharedEventDuration),
1050
+ raid: Literal((DAY * 2) / MIN, sharedEventDuration),
1051
+ }
1052
+
1053
+ const getParentEventId = ({ parent }) => parent?.attrs.eventId
1054
+ const getEventId = ({ event }) => event?.id
1055
+ const sharedEventId = {
1056
+ type: 'number',
1057
+ label: 'Event id',
1058
+ required: true,
1059
+ private: true,
1060
+ ...Functions({ 'own event': getEventId }),
1061
+ }
1062
+ const parentEventId = {
1063
+ ...sharedEventId,
1064
+ ...Functions({ 'parent event': getParentEventId }),
1065
+ }
1066
+ // eventId unit: positive integer
1067
+ relationAttrs.eventId = {
1068
+ campus: { module: sharedEventId, piscine: sharedEventId },
1069
+ piscine: {
1070
+ quest: parentEventId,
1071
+ project: parentEventId,
1072
+ exam: sharedEventId,
1073
+ raid: sharedEventId,
1074
+ },
1075
+ quest: { exercise: parentEventId },
1076
+ exam: { exercise: parentEventId },
1077
+ module: {
1078
+ piscine: sharedEventId,
1079
+ project: parentEventId,
1080
+ exam: sharedEventId,
1081
+ },
1082
+ }
1083
+
1084
+ const sharedEventStartDelay = Literal(0, {
1085
+ label: 'Time between registration and event',
1086
+ editable: true,
1087
+ })
1088
+ attrs.eventStartDelay = {
1089
+ exam: sharedEventStartDelay,
1090
+ module: sharedEventStartDelay,
1091
+ interview: sharedEventStartDelay,
1092
+ piscine: sharedEventStartDelay,
1093
+ raid: sharedEventStartDelay,
1094
+ }
1095
+
1096
+ // TODO: add a fake name by default or rm check not empty
1097
+ // because when the value is added in front end, it saves the default value,
1098
+ // which is '' but the save is blocked by check
1099
+ // or create a state in array type
1100
+ types.filesName = Literal('', {
1101
+ label: 'Expected file name',
1102
+ instruction: 'Examples: "main.go", or "exerciseName/main.go"',
1103
+ editable: true,
1104
+ check: value => {
1105
+ // checkNotEmpty(value)
1106
+ checkTextLength(value, 65)
1107
+ // TODO: check that there is a .extension
1108
+ },
1109
+ })
1110
+ const sharedExpectedFiles = {
1111
+ type: [types.filesName],
1112
+ value: [],
1113
+ editable: true,
1114
+ restrictive: true,
1115
+ label: 'Expected files',
1116
+ check: checkNotEmpty,
1117
+ }
1118
+ // expectedFiles unit: array of strings
1119
+ attrs.expectedFiles = {
1120
+ exercise: sharedExpectedFiles,
1121
+ raid: sharedExpectedFiles,
1122
+ }
1123
+
1124
+ const totalRequiredXp = (acc, c) => acc + (isRequired(c) ? c.attrs.baseXp : 0)
1125
+ const getRequiredExpectedXp = object =>
1126
+ _children(object).filter(firstOfGroup).reduce(totalRequiredXp, 0)
1127
+ const totalExpectedXp = (acc, c) => acc + (c.attrs.expectedXp || 0)
1128
+ const getTotalExpectedXp = object =>
1129
+ _children(object).reduce(totalExpectedXp, 0)
1130
+ const sharedExpectedXp = {
1131
+ type: 'number',
1132
+ label: 'XP',
1133
+ instruction: 'Expertise(s) that this content is worth.',
1134
+ required: true,
1135
+ check: amount => {
1136
+ if (!Number.isInteger(amount)) throw Error('Must be a whole number')
1137
+ if (amount < 0) throw Error('Must not be negative')
1138
+ },
1139
+ }
1140
+ // expectedXp unit: integer
1141
+ attrs.expectedXp = {
1142
+ piscine: {
1143
+ ...sharedExpectedXp,
1144
+ private: true,
1145
+ label: 'XP (for stats)', // expected from quests and exams & for module
1146
+ ...Functions({ 'Sum of children expected XP': getTotalExpectedXp }),
1147
+ }, // used in event service and for stats
1148
+ module: {
1149
+ ...sharedExpectedXp,
1150
+ ...Functions({ 'Sum of children expected XP': getTotalExpectedXp }),
1151
+ }, // TODO: check if really used??
1152
+ quest: {
1153
+ ...sharedExpectedXp,
1154
+ ...Functions({ 'Sum of required children XP': getRequiredExpectedXp }),
1155
+ },
1156
+ exam: {
1157
+ ...sharedExpectedXp,
1158
+ ...Functions({ 'Sum of required children XP': getRequiredExpectedXp }),
1159
+ },
1160
+ }
1161
+
1162
+ const primaryKeyDef = Literal('', {
1163
+ primary: true,
1164
+ editable: true,
1165
+ required: true,
1166
+ })
1167
+
1168
+ const inputKeyAndRequiredProps = {
1169
+ key: {
1170
+ ...primaryKeyDef,
1171
+ label: 'Key (must be unique)',
1172
+ instruction: 'Associated keyword in the database and data exports',
1173
+ check: (value, object) => {
1174
+ checkNotEmpty(value.trim())
1175
+
1176
+ const isNotFormatted = isNotCamel(value)
1177
+ if (isNotFormatted) {
1178
+ const camelKey = toCamelCase(value)
1179
+ throw Error(`Must be written in camelCase. Suggestion: "${camelKey}"`)
1180
+ }
1181
+
1182
+ // TODO: as key is defined like `primary`, this check should be done
1183
+ // by check-refs/object service/expand attrs (to be confirmed)
1184
+ const sameInputKeys = (
1185
+ object.attrs['form-en'] ||
1186
+ object.attrs.form ||
1187
+ // TODO: rm when add object is fixed with required attrs saved in db
1188
+ []
1189
+ )
1190
+ .flatMap(section => section.inputs.map(input => input.key))
1191
+ .filter(inputKey => inputKey === value)
1192
+ if (sameInputKeys?.length > 1) {
1193
+ throw Error(
1194
+ `${value} is already used for another input. Please choose a unique key.`,
1195
+ )
1196
+ }
1197
+ },
1198
+ },
1199
+ required: Literal(true, { label: 'Must be fulfilled', editable: true }),
1200
+ }
1201
+
1202
+ // few flags in generatedFlags from ../design/createCssClass.js
1203
+ const generatedFlags = new Set(['w100p', 'w18p'])
1204
+ const keyStyleAndRequiredProps = {
1205
+ ...inputKeyAndRequiredProps,
1206
+ styleProps: TypeObject({
1207
+ // TODO: change to array, and define instruction for each selected available design flags
1208
+ label: 'Input style',
1209
+ type: {
1210
+ w100p: Literal(false, {
1211
+ label: 'Large input style',
1212
+ editable: true,
1213
+ }),
1214
+ w18p: Literal(false, {
1215
+ label: 'Small input style',
1216
+ editable: true,
1217
+ }),
1218
+ },
1219
+ editable: true,
1220
+ check: value => {
1221
+ const invalidProp = Object.keys(value).find(
1222
+ prop => !generatedFlags.has(prop),
1223
+ )
1224
+ if (invalidProp) throw Error(`${invalidProp} is not a valid css property`)
1225
+ },
1226
+ }),
1227
+ }
1228
+ const nameAndIdProps = {
1229
+ name: Literal('', {
1230
+ label: 'Input HTML name',
1231
+ editable: true,
1232
+ check: checkNotEmpty,
1233
+ }),
1234
+ id: Literal('', {
1235
+ label: 'Input HTML id',
1236
+ editable: true,
1237
+ check: checkNotEmpty,
1238
+ }), // id could be key by default?
1239
+ }
1240
+ const labelAndPLaceholder = (defaultLabel, defaultPlaceholder) => ({
1241
+ placeholder: Literal(defaultPlaceholder || 'Your answer...', {
1242
+ label: 'Placeholder',
1243
+ editable: true,
1244
+ }),
1245
+ label: Literal(defaultLabel || '', {
1246
+ label: 'Label / title',
1247
+ instruction: 'Displayed on top of the input',
1248
+ editable: true,
1249
+ // TODO: add this!!
1250
+ // required: true
1251
+ }),
1252
+ })
1253
+ const sharedTextProps = {
1254
+ ...labelAndPLaceholder(),
1255
+ ...nameAndIdProps,
1256
+ minLength: Literal(1, { label: 'Minimum length', editable: true }),
1257
+ maxLength: Literal(60, { label: 'Maximum length', editable: true }),
1258
+ }
1259
+
1260
+ types.textInput = TypeObject({
1261
+ label: 'Text input',
1262
+ type: {
1263
+ type: Literal('text', { label: 'Input type', required: true }), // private: true?
1264
+ ...keyStyleAndRequiredProps,
1265
+ ...sharedTextProps,
1266
+ size: Literal(20, { label: 'Size', editable: true }),
1267
+ },
1268
+ })
1269
+ types.textareaInput = TypeObject({
1270
+ label: 'Textarea input',
1271
+ type: {
1272
+ type: Literal('textarea', { label: 'Input type', required: true }), // private: true?
1273
+ ...keyStyleAndRequiredProps,
1274
+ ...sharedTextProps,
1275
+ rows: Literal(5, { label: 'Rows', editable: true }),
1276
+ cols: Literal(20, { label: 'Columns', editable: true }),
1277
+ },
1278
+ })
1279
+
1280
+ types.telInputPattern = Literal(`/^\\s?\\+?[0-9 ()_-]+$/`, {
1281
+ label: 'Pattern to match',
1282
+ instruction:
1283
+ "Regular expression (in javascript) that the input's value must match.",
1284
+ editable: true,
1285
+ })
1286
+
1287
+ types.telInput = TypeObject({
1288
+ label: 'Tel input',
1289
+ type: {
1290
+ type: Literal('tel', { label: 'Input type', required: true }), // private: true?
1291
+ ...keyStyleAndRequiredProps,
1292
+ ...nameAndIdProps,
1293
+ ...labelAndPLaceholder('Telephone number', '+333 33 33 33 33'),
1294
+ pattern: types.telInputPattern,
1295
+ format: Literal('Format: +7 777 123 00 00', {
1296
+ label: 'Format to respect',
1297
+ editable: true,
1298
+ }),
1299
+ },
1300
+ })
1301
+ types.checkboxInput = TypeObject({
1302
+ label: 'Checkbox input',
1303
+ type: {
1304
+ type: Literal('checkbox', { label: 'Input type', required: true }), // private: true?
1305
+ ...inputKeyAndRequiredProps,
1306
+ ...nameAndIdProps,
1307
+ label: Literal('I agree', {
1308
+ label: 'Text associated to the checkbox',
1309
+ instruction: 'Displayed on the right of the checkbox',
1310
+ editable: true,
1311
+ required: true,
1312
+ check: checkNotEmpty,
1313
+ }),
1314
+ value: Literal(false, {
1315
+ label: 'Is initially checked',
1316
+ editable: true,
1317
+ required: true,
1318
+ }),
1319
+ // TODO: add items??
1320
+ },
1321
+ })
1322
+
1323
+ types.switchInput = TypeObject({
1324
+ label: 'Switch input',
1325
+ type: {
1326
+ type: Literal('switch', { label: 'Input type', required: true }), // private: true?
1327
+ ...inputKeyAndRequiredProps,
1328
+ ...nameAndIdProps,
1329
+ label: Literal('I choose...', {
1330
+ label: 'Text associated to the switch input',
1331
+ instruction: 'Displayed on the left of the checkbox',
1332
+ editable: true,
1333
+ required: true,
1334
+ check: checkNotEmpty,
1335
+ }),
1336
+ value: Literal(false, {
1337
+ label: 'Is initially set to true',
1338
+ editable: true,
1339
+ required: true,
1340
+ }),
1341
+ },
1342
+ })
1343
+
1344
+ types.numberInputsSteps = Literal(10, {
1345
+ label: 'Step value up and down by',
1346
+ editable: true,
1347
+ })
1348
+
1349
+ types.numberInput = TypeObject({
1350
+ label: 'Number input',
1351
+ type: {
1352
+ type: Literal('number', { label: 'Input type', required: true }), // private: true?
1353
+ ...keyStyleAndRequiredProps,
1354
+ ...nameAndIdProps,
1355
+ ...labelAndPLaceholder('How many/much...', 'Multiple of...'),
1356
+ min: Literal(1, { label: 'Minimum', editable: true }),
1357
+ max: Literal(100, { label: 'Maximum', editable: true }),
1358
+ step: types.numberInputsSteps,
1359
+ },
1360
+ })
1361
+
1362
+ types.dateInput = TypeObject({
1363
+ label: 'Date input',
1364
+ type: {
1365
+ type: Literal('date', { label: 'Input type', required: true }), // private: true?
1366
+ ...keyStyleAndRequiredProps,
1367
+ ...nameAndIdProps,
1368
+ ...labelAndPLaceholder('Date', '2000-01-01'),
1369
+ value: Literal('2000-01-01', {
1370
+ label: 'Default date value',
1371
+ editable: true,
1372
+ }),
1373
+ min: Literal('2000-01-01', {
1374
+ label: 'Lowest date allowed',
1375
+ editable: true,
1376
+ }),
1377
+ max: Literal('2000-01-01', {
1378
+ label: 'Highest date allowed',
1379
+ editable: true,
1380
+ }),
1381
+ },
1382
+ })
1383
+ types.datetimeLocalInput = TypeObject({
1384
+ label: 'Date with time input',
1385
+ type: {
1386
+ type: Literal('datetime-local', { label: 'Input type', required: true }), // private: true?
1387
+ ...keyStyleAndRequiredProps,
1388
+ ...nameAndIdProps,
1389
+ ...labelAndPLaceholder('Date and time', '2000-01-01T00:00'),
1390
+ value: Literal('2000-01-01T00:00', {
1391
+ label: 'Default date and time value',
1392
+ editable: true,
1393
+ }),
1394
+ min: Literal('2000-01-01T00:00', {
1395
+ label: 'Lowest date and time allowed',
1396
+ editable: true,
1397
+ }),
1398
+ max: Literal('2000-01-01T00:00', {
1399
+ label: 'Highest date and time allowed',
1400
+ editable: true,
1401
+ }),
1402
+ },
1403
+ })
1404
+
1405
+ types.countriesInput = TypeObject({
1406
+ label: 'Countries input',
1407
+ type: {
1408
+ type: Literal('countries', { label: 'Input type', required: true }), // private: true?
1409
+ ...inputKeyAndRequiredProps,
1410
+ ...nameAndIdProps,
1411
+ label: Literal('Country', {
1412
+ label: 'Label / title',
1413
+ instruction: 'Displayed on top of the input',
1414
+ editable: true,
1415
+ // required: true,
1416
+ }),
1417
+ emptyItem: TypeObject({
1418
+ label: 'Empty selection text',
1419
+ editable: true,
1420
+ instruction: 'Displayed as a placeholder before selection',
1421
+ type: {
1422
+ label: Literal('Choose your country...', {
1423
+ label: 'Label',
1424
+ editable: true,
1425
+ }),
1426
+ },
1427
+ }),
1428
+ },
1429
+ })
1430
+ types.languagesInput = TypeObject({
1431
+ label: 'Languages input',
1432
+ type: {
1433
+ type: Literal('languages', { label: 'Input type', required: true }), // private: true?
1434
+ ...inputKeyAndRequiredProps,
1435
+ ...nameAndIdProps,
1436
+ label: Literal('Language', {
1437
+ label: 'Label / title',
1438
+ instruction: 'Displayed on top of the input',
1439
+ editable: true,
1440
+ // required: true,
1441
+ }),
1442
+ emptyItem: TypeObject({
1443
+ label: 'Empty selection text',
1444
+ editable: true,
1445
+ instruction: 'Displayed as a placeholder before selection',
1446
+ type: {
1447
+ label: Literal('Choose your language...', {
1448
+ label: 'Label',
1449
+ editable: true,
1450
+ }),
1451
+ },
1452
+ }),
1453
+ },
1454
+ })
1455
+
1456
+ // TODO: handle input type file? until now, handled by upload step
1457
+ // accept, capture, and multiple properties to add
1458
+ // types.fileInput = TypeObject({})
1459
+
1460
+ // radio must have data property
1461
+ types.radioItems = TypeObject({
1462
+ label: 'Radio item',
1463
+ type: {
1464
+ label: Literal('', {
1465
+ label: 'Label (text displayed)',
1466
+ editable: true,
1467
+ }),
1468
+ data: Literal('', {
1469
+ label: 'Data (value saved in database)',
1470
+ editable: true,
1471
+ required: true,
1472
+ primary: true,
1473
+ check: checkNotEmpty, // TODO: rm?
1474
+ }),
1475
+ },
1476
+ })
1477
+ types.radioInput = TypeObject({
1478
+ label: 'Radio input',
1479
+ type: {
1480
+ type: Literal('radio', { label: 'Input type', required: true }), // private: true?
1481
+ ...inputKeyAndRequiredProps,
1482
+ ...nameAndIdProps,
1483
+ label: Literal('Select one...', {
1484
+ label: 'Label / title',
1485
+ instruction: 'Displayed on top of the input',
1486
+ editable: true,
1487
+ required: true,
1488
+ }),
1489
+ inlineBlock: Literal(true, {
1490
+ label: 'Displays options under the label',
1491
+ instruction: 'If set to false, options are right to the label',
1492
+ editable: true,
1493
+ }),
1494
+ items: {
1495
+ value: [],
1496
+ type: [types.radioItems],
1497
+ label: 'Items',
1498
+ required: true,
1499
+ editable: true,
1500
+ },
1501
+ },
1502
+ })
1503
+
1504
+ // select must have label property
1505
+ types.selectItems = TypeObject({
1506
+ label: 'Select item',
1507
+ type: {
1508
+ label: Literal('', {
1509
+ label: 'Label',
1510
+ instruction: 'Text displayed and saved in database', // rm?
1511
+ editable: true,
1512
+ required: true,
1513
+ primary: true,
1514
+ check: checkNotEmpty, // TODO: rm?
1515
+ }),
1516
+ data: Literal('', { label: 'Data', editable: true }),
1517
+ },
1518
+ })
1519
+ types.selectInput = TypeObject({
1520
+ label: 'Select input',
1521
+ type: {
1522
+ type: Literal('select', { label: 'Input type', required: true }), // private: true?
1523
+ ...keyStyleAndRequiredProps, // style???
1524
+ ...nameAndIdProps,
1525
+ label: Literal('Select one...', {
1526
+ label: 'Label / title',
1527
+ instruction: 'Displayed on top of the input',
1528
+ editable: true,
1529
+ // required: true, // TODO: add this?
1530
+ }),
1531
+ searchable: Literal(true, {
1532
+ label: 'Searchable',
1533
+ instruction: 'Allow search, and sort the input options',
1534
+ editable: true,
1535
+ }),
1536
+ emptyItem: TypeObject({
1537
+ label: 'Empty selection text',
1538
+ editable: true,
1539
+ instruction: 'Displayed as a placeholder before selection',
1540
+ type: {
1541
+ label: Literal('Choose...', {
1542
+ label: 'Label',
1543
+ editable: true,
1544
+ }),
1545
+ },
1546
+ }),
1547
+ items: {
1548
+ type: [types.selectItems],
1549
+ value: [],
1550
+ label: 'Items',
1551
+ required: true,
1552
+ editable: true,
1553
+ },
1554
+ },
1555
+ })
1556
+
1557
+ types.formSectionResume = Literal('', {
1558
+ label: 'Resume',
1559
+ editable: true,
1560
+ })
1561
+
1562
+ types.formSection = TypeObject({
1563
+ type: {
1564
+ key: {
1565
+ ...primaryKeyDef,
1566
+ label: 'Section id',
1567
+ instruction: 'Private info (for admins)',
1568
+ check: (value, object) => {
1569
+ checkNotEmpty(value.trim())
1570
+ const isNotFormatted = isNotCamel(value)
1571
+ if (isNotFormatted) {
1572
+ const camelKey = toCamelCase(value)
1573
+ throw Error(`Must be written in camelCase. Suggestion: "${camelKey}"`)
1574
+ }
1575
+
1576
+ // TODO: as key is defined like `primary`, this check should be done
1577
+ // by check-refs/object service/expand attrs
1578
+ const sameSectionKeys = (
1579
+ object.attrs['form-en'] ||
1580
+ object.attrs.form ||
1581
+ // TODO: rm when add object is fixed with required attrs saved in db
1582
+ []
1583
+ )
1584
+ .map(section => section.key)
1585
+ .filter(sectionKey => sectionKey === value)
1586
+ if (sameSectionKeys?.length > 1) {
1587
+ throw Error(
1588
+ `${value} is already used for another section. Please choose a unique key.`,
1589
+ )
1590
+ }
1591
+ },
1592
+ },
1593
+ title: Literal('', { label: 'Section title', editable: true }),
1594
+ resume: types.formSectionResume,
1595
+ inputs: {
1596
+ label: 'Inputs',
1597
+ instruction: 'Questions or instruction to answer to',
1598
+ required: true,
1599
+ editable: true,
1600
+ // check: checkNotEmpty, // TODO: add modal to force putting first item?
1601
+ value: [],
1602
+ // TODO: handle one or any in the arrays
1603
+ type: [
1604
+ types.textInput,
1605
+ types.textareaInput,
1606
+ types.telInput,
1607
+ types.checkboxInput,
1608
+ types.switchInput,
1609
+ types.dateInput,
1610
+ types.datetimeLocalInput,
1611
+ types.numberInput,
1612
+ // types.fileInput, - supposed to be in upload step, not form
1613
+ types.countriesInput,
1614
+ types.languagesInput,
1615
+ types.radioInput,
1616
+ types.selectInput,
1617
+ ],
1618
+ },
1619
+ },
1620
+ })
1621
+
1622
+ attrs.form = {
1623
+ 'form-step': {
1624
+ label: 'Form to fill',
1625
+ required: true,
1626
+ editable: true,
1627
+ value: [{ key: 'firstSection', inputs: [] }],
1628
+ type: [types.formSection],
1629
+ ...translatable,
1630
+ },
1631
+ }
1632
+
1633
+ const questGrade = object => {
1634
+ if (!object.children) return
1635
+ const children = _children(object)
1636
+ const requiredCount = children.filter(isRequired).length
1637
+ const count = children.filter(
1638
+ child => child.attrs.status === 'succeeded',
1639
+ ).length
1640
+
1641
+ return count && count / requiredCount
1642
+ }
1643
+ attrs.grade = {
1644
+ quest: Literal(0, {
1645
+ required: true,
1646
+ private: true,
1647
+ label: 'Quest grade',
1648
+ ...Functions({ 'succeeded exercises / required': questGrade }),
1649
+ }),
1650
+ }
1651
+
1652
+ types.moduleGraphViews = {
1653
+ type: 'string',
1654
+ options: ['Circular', 'Linear'],
1655
+ editable: true,
1656
+ value: '',
1657
+ }
1658
+ attrs.graphView = {
1659
+ module: {
1660
+ value: [],
1661
+ editable: true,
1662
+ label: 'Graph views',
1663
+ type: [types.moduleGraphViews],
1664
+ check: value => {
1665
+ if (value.length === 0) throw Error('Must contain at least one view')
1666
+ if (new Set(value).size !== value.length)
1667
+ throw Error('Should not contain duplicates')
1668
+ },
1669
+ },
1670
+ }
1671
+
1672
+ // about to be refactored to define group base on exercise level?
1673
+ relationAttrs.group = {
1674
+ exam: {
1675
+ exercise: Literal(1, {
1676
+ label: 'Exercise group',
1677
+ editable: true,
1678
+ options: arrayOf(100, 1),
1679
+ check: value => checkIntegerInBetween(value, 1, 100),
1680
+ }),
1681
+ },
1682
+ }
1683
+
1684
+ const getGroupId = ({ group }) => group?.id
1685
+ const sharedGroupId = {
1686
+ label: 'Group id of the user',
1687
+ type: 'number',
1688
+ required: true,
1689
+ private: true,
1690
+ ...Functions({ 'last confirmed group for this project': getGroupId }),
1691
+ }
1692
+ attrs.groupId = {
1693
+ project: sharedGroupId,
1694
+ raid: sharedGroupId,
1695
+ }
1696
+
1697
+ attrs.groupMax = {
1698
+ project: Literal(1, {
1699
+ label: 'Max. group number',
1700
+ required: true,
1701
+ editable: true,
1702
+ options: arrayOf(20, 1),
1703
+ check: (number, object) => {
1704
+ checkIntegerInBetween(number, 1, 20) // TODO: validate the max allowed
1705
+ if (object.attrs.groupMin > number) {
1706
+ throw Error('Must be bigger or equal to "Min. group number"')
1707
+ }
1708
+ },
1709
+ }),
1710
+ }
1711
+
1712
+ attrs.groupMin = {
1713
+ project: Literal(1, {
1714
+ label: 'Min. group number',
1715
+ required: true,
1716
+ editable: true,
1717
+ options: arrayOf(20, 1),
1718
+ check: (number, object) => {
1719
+ checkIntegerInBetween(number, 1, 20) // TODO: validate the max allowed
1720
+ if (object.attrs.groupMax < number) {
1721
+ throw Error('Must be smaller or equal to "Max. group number"')
1722
+ }
1723
+ },
1724
+ }),
1725
+ }
1726
+
1727
+ // TODO: write a doc on the group system? not sure it's needed
1728
+ attrs.groupSize = {
1729
+ raid: Literal(3, {
1730
+ label: 'Average group size',
1731
+ required: true,
1732
+ editable: true,
1733
+ // put literal value first, as in admin front end the first value of options
1734
+ // is considered as default value. This is for dynamic options to be handled
1735
+ options: [3, 1, 2, ...arrayOf(17, 4)],
1736
+ check: number => checkIntegerInBetween(number, 1, 20), // TODO: validate the max allowed
1737
+ }),
1738
+ }
1739
+
1740
+ const getHasStarted = ({ attrs }) => Date.now() > attrs.start
1741
+ const always = () => true
1742
+ const never = () => false
1743
+ const sharedPrivateHasStarted = {
1744
+ type: 'boolean',
1745
+ required: true,
1746
+ private: true,
1747
+ ...Functions({ 'from beginning of event': getHasStarted }),
1748
+ }
1749
+ const sharedPublicHasStared = {
1750
+ label: 'Starts when',
1751
+ type: 'boolean',
1752
+ required: true,
1753
+ ...Functions({
1754
+ 'temporal-window has started (in hackathon mode)': getHasStarted,
1755
+ 'always started': always,
1756
+ 'never starts': never,
1757
+ }),
1758
+ }
1759
+ // NB: it's a boolean but it is more understandable if it's described as a moment
1760
+ // to do so, we just add 2 fn: always and never instead of add editable: true
1761
+ relationAttrs.hasStarted = {
1762
+ campus: { module: sharedPrivateHasStarted, piscine: sharedPrivateHasStarted },
1763
+ module: {
1764
+ piscine: sharedPrivateHasStarted,
1765
+ exam: sharedPrivateHasStarted,
1766
+ // TODO: rm this for project? it is now at true as soon as the module started
1767
+ // (except for tron)
1768
+ // depends if we want to keep the option of using the temporal-window
1769
+ // then if we define a delay for projects, start and hasStarted would change
1770
+ // NB: this attribute is actually used right now to define inScope attr for projects
1771
+ project: sharedPublicHasStared,
1772
+ },
1773
+ piscine: {
1774
+ exam: sharedPrivateHasStarted,
1775
+ raid: sharedPrivateHasStarted,
1776
+ quest: sharedPublicHasStared,
1777
+ project: sharedPublicHasStared,
1778
+ },
1779
+ quest: {
1780
+ exercise: sharedPublicHasStared,
1781
+ },
1782
+ exam: {
1783
+ exercise: sharedPublicHasStared,
1784
+ },
1785
+ }
1786
+
1787
+ // TODO: rm? used only in the Calendar (that is not used anymore)
1788
+ // and only for mention of `Group 1,2 or 3` for raids...
1789
+ attrs.info = {
1790
+ raid: Literal('Group 1', {
1791
+ editable: true,
1792
+ label: 'Calendar information tag',
1793
+ check: value => checkTextLength(value, 50),
1794
+ }),
1795
+ }
1796
+
1797
+ const sharedInput = {
1798
+ label: 'Upload input',
1799
+ required: true,
1800
+ editable: true,
1801
+ type: 'object',
1802
+ value: {},
1803
+ ...translatable,
1804
+ instruction: 'Required option: "type"',
1805
+ }
1806
+ attrs.input = {
1807
+ 'upload-step': {
1808
+ ...sharedInput,
1809
+ check: value => {
1810
+ const [inputValues] = Object.values(value)
1811
+ isntObjectOrIsEmpty(inputValues)
1812
+ if (!inputValues.type || inputValues.type !== 'file') {
1813
+ throw Error(
1814
+ '"type":"file" property must be defined in the upload input.',
1815
+ )
1816
+ }
1817
+ if (
1818
+ inputValues.accept !== undefined &&
1819
+ typeof inputValues.accept !== 'string'
1820
+ ) {
1821
+ throw Error(
1822
+ '"accept" property (if added) must be a text. Example: "image/png, image/jpeg"',
1823
+ )
1824
+ }
1825
+ if (
1826
+ inputValues.required !== undefined &&
1827
+ typeof inputValues.required !== 'boolean'
1828
+ ) {
1829
+ throw Error('"required" property (if added) must be a true or false.')
1830
+ }
1831
+ },
1832
+ },
1833
+ 'contact-validation-step': {
1834
+ ...sharedInput,
1835
+ check: value => {
1836
+ const [inputValues] = Object.values(value)
1837
+ isntObjectOrIsEmpty(inputValues)
1838
+ if (!inputValues.type || inputValues.type !== 'tel') {
1839
+ throw Error(
1840
+ '"type":"tel" property must be defined in the contact validation input.',
1841
+ )
1842
+ }
1843
+ if (
1844
+ inputValues.required !== undefined &&
1845
+ typeof inputValues.required !== 'boolean'
1846
+ ) {
1847
+ throw Error('"required" property (if added) must be a true or false.')
1848
+ }
1849
+ if (
1850
+ inputValues.label !== undefined &&
1851
+ typeof inputValues.label !== 'string'
1852
+ ) {
1853
+ throw Error(
1854
+ '"label" property (if added) must be a text. Example: "Phone contact"',
1855
+ )
1856
+ }
1857
+ if (
1858
+ inputValues.placeholder !== undefined &&
1859
+ typeof inputValues.placeholder !== 'string'
1860
+ ) {
1861
+ throw Error(
1862
+ '"placeholder" property (if added) must be a text. Example: "+333 33 33 33 33"',
1863
+ )
1864
+ }
1865
+ if (
1866
+ inputValues.format !== undefined &&
1867
+ typeof inputValues.format !== 'string'
1868
+ ) {
1869
+ throw Error(
1870
+ '"format" property (if added) must be a text. Example: "Required format: +33 7 17 17 17 17"',
1871
+ )
1872
+ }
1873
+ if (inputValues.pattern !== undefined) {
1874
+ if (typeof inputValues.pattern !== 'string') {
1875
+ throw Error(
1876
+ '"pattern" property (if added) must be a text and a valid regex. Example: "[+][0-9]{3}[0-9]{2}[0-9]{2}[0-9]{2}[0-9]{2}"',
1877
+ )
1878
+ }
1879
+ try {
1880
+ return new RegExp(value)
1881
+ } catch (err) {
1882
+ throw Error('Invalid Regular expression.')
1883
+ }
1884
+ }
1885
+ },
1886
+ },
1887
+ }
1888
+
1889
+ const getInScope = ({ attrs }) => {
1890
+ const now = Date.now()
1891
+ return now > attrs.scopeStart && now < attrs.scopeEnd
1892
+ }
1893
+ const getProjectInScope = ({ attrs }) => attrs.hasStarted
1894
+ const getExamExerciseInScope = ({ parent }) => parent.attrs.inScope
1895
+ const getExerciseInScope = ({ parent, attrs, index }) => {
1896
+ if (parent.attrs.inScope) {
1897
+ if (isHackathon(parent)) {
1898
+ const now = Date.now()
1899
+ return now > attrs.start && now < attrs.duration * DAY + attrs.start
1900
+ }
1901
+ return true
1902
+ }
1903
+ // when is it that bonus is always inScope? should be open if prev ex are done no?
1904
+ return parent.attrs.hasStarted && attrs.category === 'bonus'
1905
+ }
1906
+ // this should never be editable!
1907
+ const sharedInScope = {
1908
+ label: 'Reward scope',
1909
+ required: true,
1910
+ type: 'boolean',
1911
+ }
1912
+ const eventInScope = {
1913
+ ...sharedInScope,
1914
+ private: true,
1915
+ label: 'Active scope (distinct from temporal-window)',
1916
+ ...Functions({ 'from start date to end date': getInScope }), // TODO: check it is not an issue for raids - reward after end of event
1917
+ }
1918
+ // NB: it's a boolean but it is more understandable if it's described as a duration
1919
+ // if we decide it is editable one day, we should just add 2 fn: always and never
1920
+ // instead of add editable: true
1921
+ // 'always open': () => true,
1922
+ // 'never open': () => false,
1923
+ relationAttrs.inScope = {
1924
+ // TODO: should it be defined for modules items also?
1925
+ piscine: {
1926
+ quest: {
1927
+ ...sharedInScope,
1928
+ label: 'Reward scope (temporal-window with extra duration)',
1929
+ instruction:
1930
+ 'Defines whether the content keeps giving the rewards or not.',
1931
+ ...Functions({ 'from start day to end of extra duration': getInScope }),
1932
+ },
1933
+ raid: eventInScope,
1934
+ exam: eventInScope,
1935
+ project: {
1936
+ ...sharedInScope,
1937
+ ...Functions({ 'parent temporal-window': getProjectInScope }),
1938
+ },
1939
+ },
1940
+ // TODO: find a way to make it clear for hackathon exercise scope
1941
+ quest: {
1942
+ exercise: {
1943
+ ...sharedInScope,
1944
+ ...Functions({
1945
+ 'parent temporal-window (or one by one in hackathon)':
1946
+ getExerciseInScope,
1947
+ }),
1948
+ instruction:
1949
+ 'Defines whether the content keeps giving the rewards or not.',
1950
+ },
1951
+ },
1952
+ // needed?
1953
+ exam: {
1954
+ exercise: {
1955
+ ...sharedInScope,
1956
+ private: true,
1957
+ ...Functions({ 'parent scope': getExamExerciseInScope }),
1958
+ },
1959
+ },
1960
+ module: {
1961
+ project: {
1962
+ ...sharedInScope,
1963
+ ...Functions({ 'parent scope': getProjectInScope }),
1964
+ },
1965
+ exam: eventInScope,
1966
+ },
1967
+ }
1968
+
1969
+ const sharedLanguage = Literal('', {
1970
+ label: 'Programming language',
1971
+ instruction: 'Example: "js", "go", "rust"',
1972
+ editable: true,
1973
+ restrictive: true,
1974
+ })
1975
+ attrs.language = {
1976
+ exam: sharedLanguage,
1977
+ exercise: sharedLanguage,
1978
+ raid: sharedLanguage,
1979
+ project: sharedLanguage,
1980
+ }
1981
+
1982
+ const getLevel = ({ parent, attrs }) => {
1983
+ if (parent && parent.type === 'exam') return parent.attrs.level
1984
+
1985
+ const totalScore = attrs.difficulty + attrs.xpIndex
1986
+ const match = LEVELS.find(lvl => lvl.xpIndex > totalScore)
1987
+ return match ? match.level : 0
1988
+ }
1989
+ const sharedLevel = Literal(1, {
1990
+ label: 'Level',
1991
+ required: true,
1992
+ editable: true,
1993
+ editableDefaultValue: 1,
1994
+ ...Functions({ 'minimum required': getLevel }),
1995
+ options: arrayOf(MAX_LEVEL, 1),
1996
+ check: value => checkIntegerInBetween(value, 1, MAX_LEVEL),
1997
+ })
1998
+ relationAttrs.level = {
1999
+ module: { piscine: sharedLevel, exam: sharedLevel, project: sharedLevel },
2000
+ piscine: {
2001
+ quest: sharedLevel,
2002
+ raid: sharedLevel,
2003
+ exam: sharedLevel,
2004
+ project: sharedLevel,
2005
+ },
2006
+ quest: { exercise: sharedLevel },
2007
+ exam: { exercise: sharedLevel },
2008
+ // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
2009
+ campus: { piscine: sharedLevel },
2010
+ }
2011
+
2012
+ types.objectChildRelativePath = Literal('./', {
2013
+ label: 'Content relative path',
2014
+ instruction: 'As a child of this content',
2015
+ editable: true,
2016
+ check: getObjectFromRelativePath,
2017
+ options: object =>
2018
+ Object.entries(object.children || {})
2019
+ .sort(byValueIdx)
2020
+ .map(([key, _]) => `./${key}`),
2021
+ })
2022
+
2023
+ types.objectRootRelativePath = Literal('../', {
2024
+ label: 'Content relative path',
2025
+ instruction: 'In same parent',
2026
+ editable: true,
2027
+ check: getObjectFromRelativePath,
2028
+ options: object => {
2029
+ if (!object?.parent?.children) return []
2030
+ const sorted = Object.entries(object.parent.children).sort(byValueIdx)
2031
+ const index = sorted.findIndex(([k, v]) => k === object.key)
2032
+ if (index < 1) return sorted.map(([key, _]) => `../${key}`)
2033
+ const before = sorted.slice(0, index)
2034
+ // TODO: add a filter on category and let possibility to select the async after?
2035
+ // const after = sorted.slice(index + 1)
2036
+ // .filter(([k, v]) => v.attrs?.category === 'asynchronous')
2037
+ // return [...before, ...after].map(([key, _]) => `../${key}`)
2038
+ return before.map(([key, _]) => `../${key}`).reverse()
2039
+ },
2040
+ })
2041
+ types.sharedObjectList = {
2042
+ label: 'Contents', // synonyms: item, material
2043
+ editable: true,
2044
+ check: objects => {
2045
+ if (!Array.isArray(objects) || !objects.length) {
2046
+ throw Error('Must be a non empty array')
2047
+ }
2048
+ const uniques = [...new Set(objects)]
2049
+ if (objects.length !== uniques.length) {
2050
+ throw Error('Duplicates are not allowed.')
2051
+ }
2052
+ },
2053
+ }
2054
+
2055
+ const sharedRequirements = { editable: true, label: 'Access conditions' }
2056
+
2057
+ const contentRequirements = {
2058
+ ...sharedRequirements,
2059
+ instruction: 'Conditions to access this content',
2060
+ description:
2061
+ 'Sets the requirements that have to be met for a content to be accessible to a student.',
2062
+ check: (requirements, object) => {
2063
+ const { skills, objects, core, ...rest } = requirements
2064
+ if (Object.keys(rest).length) {
2065
+ throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
2066
+ }
2067
+ if (!skills && !objects && !core) {
2068
+ throw Error('Empty requirements, should be removed')
2069
+ }
2070
+ },
2071
+ }
2072
+
2073
+ const levelRequirements = {
2074
+ ...sharedRequirements,
2075
+ instruction: 'Conditions to unlock and earn this level',
2076
+ description:
2077
+ 'Sets the requirements that have to be met for a level to be unlocked and earned by a student.',
2078
+ check: (requirements, object) => {
2079
+ const { skills, objects, ...rest } = requirements
2080
+ if (Object.keys(rest).length) {
2081
+ throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
2082
+ }
2083
+ if (!skills && !objects) {
2084
+ throw Error('Empty requirements, should be removed')
2085
+ }
2086
+ },
2087
+ }
2088
+
2089
+ types.levelDefinition = TypeObject({
2090
+ label: 'Level definition',
2091
+ type: {
2092
+ level: Literal(MAX_LEVEL, {
2093
+ label: 'Level',
2094
+ editable: true,
2095
+ required: true,
2096
+ primary: true,
2097
+ // to put max level first in options - could also be [MAX_LEVEL, ...arrayOf(MAX_LEVEL -1, 1)]
2098
+ options: arrayOf(MAX_LEVEL, 1).reverse(),
2099
+ check: (level, object) => {
2100
+ checkIntegerInBetween(level, 1, MAX_LEVEL)
2101
+
2102
+ const definitionsWithSameLevel = object.attrs.levelsDefinitions.filter(
2103
+ levelDefinition => levelDefinition.level === level,
2104
+ )
2105
+ if (definitionsWithSameLevel?.length > 1) {
2106
+ throw Error(
2107
+ `Level ${level} is set for different levels definitions! A given level can only be defined once.`,
2108
+ )
2109
+ }
2110
+ },
2111
+ }),
2112
+ requirements: TypeObject({
2113
+ ...levelRequirements,
2114
+ type: {
2115
+ skills: {
2116
+ ...skillsList,
2117
+ instruction: 'Expertises required',
2118
+ },
2119
+ objects: {
2120
+ ...types.sharedObjectList,
2121
+ value: (...args) => {
2122
+ const option = types.objectChildRelativePath.options(...args)?.[0]
2123
+ return option ? [option] : []
2124
+ },
2125
+ type: [types.objectChildRelativePath],
2126
+ instruction: 'Items to be succeeded',
2127
+ },
2128
+ },
2129
+ required: true,
2130
+ instruction: 'Conditions to access this level',
2131
+ }),
2132
+ },
2133
+ })
2134
+ const sharedLevelsDefinitions = {
2135
+ label: 'Levels definitions',
2136
+ instruction: 'List of levels access conditions',
2137
+ value: (...args) => [
2138
+ mapValues(types.levelDefinition.type, subDef =>
2139
+ getDefaultValue(subDef, ...args),
2140
+ ),
2141
+ ],
2142
+ editable: true,
2143
+ restrictive: true,
2144
+ type: [types.levelDefinition],
2145
+ check: levelsDefinitions => {
2146
+ if (!levelsDefinitions?.length || !Array.isArray(levelsDefinitions)) {
2147
+ throw Error('Must be a non empty array')
2148
+ }
2149
+ },
2150
+ }
2151
+ attrs.levelsDefinitions = {
2152
+ module: sharedLevelsDefinitions,
2153
+ // TODO: solve bug on piscine: required objects that already started
2154
+ // (quests, exams, etc.) always have the status as `succeeded`
2155
+ // in the reward service when getting the object with `getObject`
2156
+ // piscine: sharedLevelsDefinitions,
2157
+ }
2158
+
2159
+ // TODO: define the type when html attributes handled/our css handled
2160
+ // as all other attributes wanted for the link can be added (e.g. target="_blank")
2161
+ attrs.link = {
2162
+ 'sign-step': TypeObject({
2163
+ label: 'Associated link to the document',
2164
+ editable: true,
2165
+ type: {
2166
+ href: Literal('', {
2167
+ required: true,
2168
+ editable: true,
2169
+ label: 'URL',
2170
+ instruction: 'Href HTML attribute',
2171
+ check: value => {
2172
+ if (value.length) {
2173
+ checkValidURL(value)
2174
+ }
2175
+ },
2176
+ }),
2177
+ label: Literal('> Link to the document', {
2178
+ editable: true,
2179
+ label: 'Label / text displayed',
2180
+ }),
2181
+ target: Literal('_blank', {
2182
+ label: 'Target HTML attribute',
2183
+ instruction: 'Open the link in a new browser tab',
2184
+ }),
2185
+ },
2186
+ ...translatable, // in case there are different versions of the doc to dl
2187
+ }),
2188
+ }
2189
+
2190
+ // TODO: should be required for exam exercises.
2191
+ // But it is about to be refactored to define group base on exercise level?
2192
+ const maxGroupReducer = (t, c) => Math.max(c.attrs.group || 1, t) // added '|| 1' as group is not required and could be undefined
2193
+ const getMaxExamGroup = object => _children(object).reduce(maxGroupReducer, 1)
2194
+ attrs.maxGroup = {
2195
+ exam: {
2196
+ type: 'number',
2197
+ label: 'Max exercise group',
2198
+ private: true, // interesting only in a context of an event
2199
+ required: true,
2200
+ ...Functions({ 'biggest exercise group of the exam': getMaxExamGroup }),
2201
+ },
2202
+ }
2203
+
2204
+ // TODO: mv handling of name in displayedName attribute
2205
+ const sharedName = Literal('', {
2206
+ label: 'Name',
2207
+ editable: true,
2208
+ ...translatable,
2209
+ })
2210
+ const attrsNameObj = {}
2211
+ for (const type of onboardingTypes) {
2212
+ attrsNameObj[type] = sharedName
2213
+ }
2214
+ attrs.name = {
2215
+ signup: sharedName,
2216
+ ...attrsNameObj,
2217
+ }
2218
+
2219
+ const getParentType = ({ parent }) => parent?.type
2220
+ const sharedParentType = {
2221
+ type: 'string',
2222
+ private: true,
2223
+ required: true,
2224
+ label: 'Upper content type',
2225
+ ...Functions({ 'from parent': getParentType }),
2226
+ }
2227
+ // TODO: define the one we need to keep?
2228
+ relationAttrs.parentType = {
2229
+ campus: {
2230
+ // module: sharedParentType,
2231
+ piscine: sharedParentType,
2232
+ },
2233
+ module: {
2234
+ piscine: sharedParentType,
2235
+ exam: sharedParentType,
2236
+ project: sharedParentType,
2237
+ },
2238
+ piscine: {
2239
+ quest: sharedParentType,
2240
+ exam: sharedParentType,
2241
+ raid: sharedParentType,
2242
+ project: sharedParentType,
2243
+ },
2244
+ quest: { exercise: sharedParentType },
2245
+ exam: { exercise: sharedParentType },
2246
+ }
2247
+
2248
+ types.rankDefinitionName = Literal('', {
2249
+ label: 'Name',
2250
+ editable: true,
2251
+ required: true,
2252
+ primary: true,
2253
+ check: (name, object) => {
2254
+ // checkNotEmpty(name): have to be commented or it's impossible
2255
+ // to add a new rank to the ranks definition, as default value is ''
2256
+ // and check is done before add
2257
+ const definitionsWithSameName = object.attrs.ranksDefinitions.filter(
2258
+ rankDefinition => rankDefinition.name === name,
2259
+ )
2260
+ if (definitionsWithSameName?.length > 1) {
2261
+ throw Error(
2262
+ `Name "${name}" is already set for a rank definition! A given name can only be attributed once to a rank.`,
2263
+ )
2264
+ }
2265
+ },
2266
+ })
2267
+
2268
+ types.rankDefinition = TypeObject({
2269
+ label: 'Rank definition',
2270
+ type: {
2271
+ name: types.rankDefinitionName,
2272
+ level: Literal(MAX_LEVEL, {
2273
+ label: 'Level required',
2274
+ editable: true,
2275
+ required: true,
2276
+ // to put max level first in options - could also be [MAX_LEVEL, ...arrayOf(MAX_LEVEL -1, 0)]
2277
+ options: arrayOf(MAX_LEVEL, 0).reverse(),
2278
+ check: (level, object) => {
2279
+ checkIntegerInBetween(level, 0, MAX_LEVEL)
2280
+
2281
+ const definitionsWithSameLevel = object.attrs.ranksDefinitions.filter(
2282
+ rankDefinition => rankDefinition.level === level,
2283
+ )
2284
+ if (definitionsWithSameLevel?.length > 1) {
2285
+ throw Error(
2286
+ `Level ${level} is already set for a rank definition! A given level can only be attributed once to a rank.`,
2287
+ )
2288
+ }
2289
+ },
2290
+ }),
2291
+ milestone: Literal('', {
2292
+ label: 'Milestone description',
2293
+ editable: true,
2294
+ }),
2295
+ },
2296
+ })
2297
+ const sharedRanksDefinitions = {
2298
+ label: 'Ranks definitions',
2299
+ instruction: 'List of ranks access conditions',
2300
+ editable: true,
2301
+ restrictive: true,
2302
+ value: (...args) => [
2303
+ mapValues(types.rankDefinition.type, subDef =>
2304
+ getDefaultValue(subDef, ...args),
2305
+ ),
2306
+ ],
2307
+ type: [types.rankDefinition],
2308
+ check: ranksDefinitions => {
2309
+ if (!ranksDefinitions?.length || !Array.isArray(ranksDefinitions)) {
2310
+ throw Error('Must be a non empty array')
2311
+ }
2312
+ },
2313
+ }
2314
+ attrs.ranksDefinitions = {
2315
+ module: sharedRanksDefinitions,
2316
+ piscine: sharedRanksDefinitions,
2317
+ }
2318
+
2319
+ const sharedRegistrationDuration = {
2320
+ label: 'Registration duration (to an event)',
2321
+ required: true,
2322
+ editable: true,
2323
+ check: duration => checkDurationInBetween(duration * MIN, 1 * MIN, 365 * DAY), // 1 min to 1 years (+1 extra day)
2324
+ }
2325
+ attrs.registrationDuration = {
2326
+ exam: Literal((1.5 * DAY) / MIN, sharedRegistrationDuration),
2327
+ module: Literal((30 * DAY) / MIN, sharedRegistrationDuration),
2328
+ interview: Literal((2 * WEEK) / MIN, sharedRegistrationDuration),
2329
+ piscine: Literal((30 * DAY) / MIN, sharedRegistrationDuration),
2330
+ raid: Literal((1.5 * DAY) / MIN, sharedRegistrationDuration),
2331
+ }
2332
+
2333
+ const GIT_URL = `https://${process.env.DOMAIN}/git/`
2334
+ const getRepositoryURL = ({ attrs }) => `${GIT_URL}${attrs.repositoryPath}`
2335
+ const sharedRepository = {
2336
+ label: 'Repository URL',
2337
+ type: 'string',
2338
+ required: true,
2339
+ private: true,
2340
+ ...Functions({ 'from gitea user account': getRepositoryURL }),
2341
+ }
2342
+ attrs.repository = {
2343
+ exercise: sharedRepository,
2344
+ project: sharedRepository,
2345
+ raid: sharedRepository,
2346
+ }
2347
+
2348
+ const getCaptainLogin = ({ group, parent }) => {
2349
+ group || (group = parent?.group)
2350
+ return group && (group.captainLogin || group.captain?.login)
2351
+ }
2352
+ const getRepositoryPath = ({ name, group, parent }, user) =>
2353
+ `${getCaptainLogin({ group, parent }) || user.login}/${name}`
2354
+ const getExerciseRepositoryPath = ({ attrs, parent, path, group }, user) => {
2355
+ const captainLogin = getCaptainLogin({ parent, group })
2356
+ return captainLogin
2357
+ ? `${captainLogin}/${parent.path.replace(/\/+/g, '-')}`
2358
+ : `${user.login}/${attrs.rootName}`
2359
+ }
2360
+ const sharedRepositoryPath = {
2361
+ label: 'Repository path',
2362
+ type: 'string',
2363
+ required: true,
2364
+ private: true, // ??
2365
+ ...Functions({ 'from captain': getRepositoryPath }),
2366
+ }
2367
+ attrs.repositoryPath = {
2368
+ exercise: {
2369
+ ...sharedRepositoryPath,
2370
+ ...Functions({ 'from captain': getExerciseRepositoryPath }),
2371
+ },
2372
+ project: sharedRepositoryPath,
2373
+ raid: sharedRepositoryPath,
2374
+ }
2375
+
2376
+ attrs.requiredAuditRatio = {
2377
+ project: Literal(0.5, {
2378
+ label: 'Audit ratio required to begin the project',
2379
+ instruction: 'From 0 to 2 (1 being the perfect balance of audits)',
2380
+ required: true,
2381
+ editable: true,
2382
+ options: [...Array(21)].map((_, i) => Math.round(i * 0.1 * 10) / 10),
2383
+ check: value => checkNumberInBetween(value, 0, 2),
2384
+ }),
2385
+ }
2386
+
2387
+ const sharedContentRequirementsForMainAttr = TypeObject({
2388
+ ...contentRequirements,
2389
+ type: {
2390
+ skills: { ...skillsList, instruction: 'Expertises required' },
2391
+ core: {
2392
+ ...types.objectRootRelativePath,
2393
+ label: 'Core content',
2394
+ instruction: 'Relative path of the core content to be succeeded',
2395
+ },
2396
+ objects: {
2397
+ ...types.sharedObjectList,
2398
+ value: (...args) => {
2399
+ const option = types.objectRootRelativePath.options(...args)?.[0]
2400
+ return option ? [option] : []
2401
+ },
2402
+ type: [types.objectRootRelativePath],
2403
+ instruction: 'Items to be succeeded',
2404
+ },
2405
+ },
2406
+ })
2407
+ relationAttrs.requirements = {
2408
+ module: {
2409
+ project: sharedContentRequirementsForMainAttr,
2410
+ piscine: sharedContentRequirementsForMainAttr,
2411
+ },
2412
+ campus: { piscine: sharedContentRequirementsForMainAttr },
2413
+ // should be open to any content: implementation to be checked when needed
2414
+ // TODO: add exercise, raid, exam, quest
2415
+ }
2416
+
2417
+ const sharedResume = Literal('', {
2418
+ label: 'Resume',
2419
+ editable: true,
2420
+ ...translatable,
2421
+ })
2422
+ attrs.resume = {
2423
+ 'module-registration': sharedResume,
2424
+ 'piscine-registration': sharedResume,
2425
+ 'form-step': sharedResume,
2426
+ interview: sharedResume,
2427
+ }
2428
+
2429
+ const getBaseSkills = ({ attrs }) => attrs.baseSkills
2430
+ const getBaseXp = ({ attrs }) => attrs.baseXp
2431
+ const getScopedBaseXp = ({ attrs }) => (attrs.inScope ? attrs.baseXp : 0)
2432
+ types.skills = {
2433
+ ...Functions({ 'collect skills': getBaseSkills }),
2434
+ label: 'Skills',
2435
+ required: true,
2436
+ }
2437
+ const sharedRewards = {
2438
+ required: true,
2439
+ label: 'Reward conditions',
2440
+ instruction: 'Conditions to collect xp and skills when succeeded',
2441
+ }
2442
+ const rewardsOfSkillsAndXpInScope = TypeObject({
2443
+ type: {
2444
+ skills: types.skills,
2445
+ xp: {
2446
+ ...Functions({ 'collect xp in scope': getScopedBaseXp }),
2447
+ label: 'XP',
2448
+ required: true,
2449
+ },
2450
+ },
2451
+ ...sharedRewards,
2452
+ })
2453
+ relationAttrs.rewards = {
2454
+ piscine: {
2455
+ raid: TypeObject({
2456
+ type: {
2457
+ skills: types.skills,
2458
+ xp: {
2459
+ ...Functions({ 'collect xp': getBaseXp }),
2460
+ label: 'XP',
2461
+ required: true,
2462
+ },
2463
+ },
2464
+ ...sharedRewards,
2465
+ }),
2466
+ project: rewardsOfSkillsAndXpInScope,
2467
+ },
2468
+ module: {
2469
+ piscine: TypeObject({
2470
+ type: {
2471
+ skills: types.skills,
2472
+ xp: {
2473
+ ...Functions({ 'collect xp': getBaseXp }),
2474
+ label: 'XP',
2475
+ required: true,
2476
+ },
2477
+ },
2478
+ ...sharedRewards,
2479
+ }),
2480
+ project: rewardsOfSkillsAndXpInScope,
2481
+ },
2482
+ // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
2483
+ campus: {
2484
+ piscine: TypeObject({
2485
+ type: {
2486
+ skills: types.skills,
2487
+ xp: {
2488
+ ...Functions({ 'collect xp': getBaseXp }),
2489
+ label: 'XP',
2490
+ required: true,
2491
+ },
2492
+ },
2493
+ ...sharedRewards,
2494
+ }),
2495
+ },
2496
+ quest: { exercise: rewardsOfSkillsAndXpInScope },
2497
+ exam: { exercise: rewardsOfSkillsAndXpInScope },
2498
+ }
2499
+ const rewardParent = ({ parent }) => {
2500
+ // cannot keep all parent or get an infinite loop
2501
+ const { attrs, name, type, id, path } = parent
2502
+ // keep a classical object structure (as we would retrieve it with path feature)
2503
+ return type !== 'campus'
2504
+ ? { attrs: { eventId: attrs.eventId }, name, type, id, path }
2505
+ : undefined
2506
+ }
2507
+ relationAttrs.rewardsTarget = {
2508
+ module: {
2509
+ piscine: {
2510
+ type: 'object',
2511
+ label: 'Target of the rewards',
2512
+ instruction: 'Content to which rewards are attributed',
2513
+ required: true,
2514
+ ...Functions({ 'parent event if exists': rewardParent }),
2515
+ },
2516
+ },
2517
+ }
2518
+
2519
+ const getRootName = ({ parent }) => {
2520
+ while (parent) {
2521
+ if (parent.type === 'module' || parent.type === 'piscine') return parent.key
2522
+ parent = parent.parent
2523
+ }
2524
+ return ''
2525
+ }
2526
+ const sharedRootName = {
2527
+ type: 'string',
2528
+ // TODO: verify if ok: useless to be seen by admin in object edit,
2529
+ // but is needed in front end; can be private?
2530
+ private: true,
2531
+ ...Functions({ 'root content key': getRootName }),
2532
+ required: true,
2533
+ label: 'Root key',
2534
+ }
2535
+ relationAttrs.rootName = {
2536
+ quest: { exercise: sharedRootName },
2537
+ exam: { exercise: sharedRootName },
2538
+ module: { project: sharedRootName },
2539
+ piscine: { project: sharedRootName, raid: sharedRootName },
2540
+ }
2541
+
2542
+ const getRootPath = ({ parent }) => {
2543
+ while (parent) {
2544
+ if (parent.type === 'module' || parent.type === 'piscine') {
2545
+ return parent.path
2546
+ }
2547
+ parent = parent.parent
2548
+ }
2549
+ }
2550
+ const sharedRootPath = {
2551
+ type: 'string',
2552
+ required: true,
2553
+ private: true,
2554
+ ...Functions({ 'root content path': getRootPath }),
2555
+ label: 'Root path',
2556
+ }
2557
+ relationAttrs.rootPath = {
2558
+ module: {
2559
+ project: sharedRootPath,
2560
+ piscine: sharedRootPath, // module rootPath needed
2561
+ exam: sharedRootPath,
2562
+ },
2563
+ piscine: {
2564
+ project: sharedRootPath,
2565
+ raid: sharedRootPath,
2566
+ exam: sharedRootPath,
2567
+ quest: sharedRootPath,
2568
+ },
2569
+ quest: { exercise: sharedRootPath },
2570
+ exam: { exercise: sharedRootPath },
2571
+ }
2572
+
2573
+ const questScopeEnd = object =>
2574
+ getQuestExtraEndAt(object.attrs.scopeStart, object)
2575
+ const getEventScopeEnd = ({ event }) => event && numTime(event.endAt)
2576
+ // NB: Used for the timer in the front
2577
+ const sharedScopeEnd = {
2578
+ type: 'number',
2579
+ required: true,
2580
+ private: true, // not necessary: admins already see temporal-window and reward scope
2581
+ }
2582
+ const eventScopeEnd = {
2583
+ ...sharedScopeEnd,
2584
+ label: 'Active scope end',
2585
+ ...Functions({ 'when event ends': getEventScopeEnd }),
2586
+ }
2587
+ const extraDurationScopeEnd = {
2588
+ ...sharedScopeEnd,
2589
+ label: 'Reward scope end',
2590
+ ...Functions({ 'when extra duration ends': questScopeEnd }),
2591
+ }
2592
+ relationAttrs.scopeEnd = {
2593
+ piscine: {
2594
+ raid: eventScopeEnd,
2595
+ // rm? (for exam) questScopeEnd was applied (which makes no sense)
2596
+ // so I don't think we use it. Maybe we don't need inScope/startScope either then
2597
+ // also it seems that timer is used only in ProjectView and QuestView
2598
+ exam: eventScopeEnd,
2599
+ quest: extraDurationScopeEnd,
2600
+ project: extraDurationScopeEnd,
2601
+ },
2602
+ // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
2603
+ module: { project: extraDurationScopeEnd, exam: eventScopeEnd },
2604
+ }
2605
+
2606
+ const sharedScopeExtraDuration = Literal(0, {
2607
+ label: 'Extra duration (for reward scope)',
2608
+ editable: true,
2609
+ check: duration => checkDurationInBetween(duration * DAY, 0, 50 * DAY), // TODO: maybe max should be another int
2610
+ })
2611
+
2612
+ relationAttrs.scopeExtraDuration = {
2613
+ piscine: {
2614
+ quest: sharedScopeExtraDuration,
2615
+ project: sharedScopeExtraDuration,
2616
+ },
2617
+ }
2618
+
2619
+ // TODO: this attribute only get the start attribute. Maybe we could rm it
2620
+ const getScopeStart = ({ attrs }) => attrs.start
2621
+ const sharedScopeStart = {
2622
+ type: 'number',
2623
+ required: true,
2624
+ private: true, // not necessary - admin already see temporal-window and reward scope
2625
+ ...Functions({ 'from start date': getScopeStart }),
2626
+ }
2627
+ const sharedEventScopeStart = {
2628
+ ...sharedScopeStart,
2629
+ label: 'Active scope start',
2630
+ }
2631
+ const temporalWindowScopeStart = {
2632
+ ...sharedScopeStart,
2633
+ label: 'Temporal-window & reward scope start',
2634
+ }
2635
+ relationAttrs.scopeStart = {
2636
+ piscine: {
2637
+ quest: temporalWindowScopeStart,
2638
+ project: temporalWindowScopeStart,
2639
+ raid: sharedEventScopeStart,
2640
+ exam: sharedEventScopeStart,
2641
+ },
2642
+ module: { exam: sharedEventScopeStart, project: temporalWindowScopeStart },
2643
+ }
2644
+
2645
+ const getSpecial = ({ parent }) => parent?.attrs.special
2646
+ attrs.special = {
2647
+ quest: Literal(false, {
2648
+ label: 'Activate hackathon mode',
2649
+ editable: true,
2650
+ restrictive: true,
2651
+ }),
2652
+ exercise: {
2653
+ // not private because it helps the admin to identify the exercise as special/in hackathon mode
2654
+ label: 'In hackathon mode',
2655
+ type: 'boolean',
2656
+ required: true, // To be sure it will always be there if the quest is special
2657
+ ...Functions({ 'activated with the quest': getSpecial }),
2658
+ },
2659
+ }
2660
+
2661
+ // getStart returns date in milliseconds
2662
+ const getStart = object => {
2663
+ const match = findParent(object, hasEvent)
2664
+ const event = match?.event
2665
+
2666
+ if (event) {
2667
+ const time = numTime(event.startAt)
2668
+ return match === object ? time : getQuestStartAt(time, object)
2669
+ }
2670
+ if (!object.parent) return
2671
+ // TODO: rm this part? should always find a match no? what is the no case?
2672
+ return getQuestStartAt(object.parent.attrs.start, object)
2673
+ }
2674
+ const sharedStart = {
2675
+ label: 'Start date',
2676
+ type: 'number',
2677
+ required: true,
2678
+ private: true, // all infos are defined in duration and inScope attributes
2679
+ ...Functions({ 'from start date of event': getStart }),
2680
+ }
2681
+ const projectStart = {
2682
+ // stay private - not really useful now, as access is defined by requirements & type (could be confusing otherwise)
2683
+ ...sharedStart,
2684
+ ...Functions({ 'starts with module': getStart }),
2685
+ }
2686
+ const getExerciseStart = ({ parent, attrs }) =>
2687
+ parent?.attrs.start + attrs.delay
2688
+ const exerciseStart = {
2689
+ ...sharedStart,
2690
+ ...Functions({
2691
+ 'from temporal-window (if in hackathon mode)': getExerciseStart,
2692
+ }),
2693
+ }
2694
+ relationAttrs.start = {
2695
+ campus: {
2696
+ module: sharedStart,
2697
+ piscine: sharedStart,
2698
+ },
2699
+ module: {
2700
+ exam: sharedStart,
2701
+ piscine: sharedStart,
2702
+ // TODO: rm? or could be used with temporal-window, if we define a delay (could be an option)
2703
+ // NB: this attribute is used to calculate hasStarted and inScope attributes
2704
+ project: projectStart,
2705
+ },
2706
+ piscine: {
2707
+ raid: sharedStart,
2708
+ exam: sharedStart,
2709
+ // non event ones
2710
+ quest: {
2711
+ ...sharedStart,
2712
+ ...Functions({ 'from temporal-window': getStart }),
2713
+ },
2714
+ project: projectStart,
2715
+ },
2716
+ quest: { exercise: exerciseStart },
2717
+ // exam: { exercise: exerciseStart }, // needed?
2718
+ }
2719
+
2720
+ // TODO: should also be handled differently in the event service
2721
+ // if no startAfter is defined, children events should not be pre-created.
2722
+ const sharedStartAfter = Literal(0, {
2723
+ label: 'Starts after a delay of (within the event)',
2724
+ editable: true,
2725
+ required: true,
2726
+ // check: () => {
2727
+ // check that if there is a prev event with startAfter, it is bigger number here
2728
+ // },
2729
+ })
2730
+
2731
+ relationAttrs.startAfter = {
2732
+ module: { piscine: sharedStartAfter, exam: sharedStartAfter },
2733
+ piscine: { exam: sharedStartAfter, raid: sharedStartAfter },
2734
+ // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
2735
+ campus: { piscine: sharedStartAfter },
2736
+ }
2737
+
2738
+ // TODO: this should be defined in the relation
2739
+ // this attribute is not editable!
2740
+ // to change the startDay, admins should update the duration of the elements
2741
+ const getStartDay = ({ prev }) =>
2742
+ prev ? prev.attrs.duration + prev.attrs.startDay : 1
2743
+ const getExamStartDay = ({ parent, prev }) =>
2744
+ parent.type === 'piscine' ? getStartDay({ prev }) : undefined
2745
+ const sharedStartDay = Literal(1, {
2746
+ label: 'Start day n°',
2747
+ // TODO: find a smaller name for the fn
2748
+ ...Functions({ 'from end of previous content temporal-window': getStartDay }),
2749
+ required: true,
2750
+ private: true, // would be interesting to show only in an event context
2751
+ // some more infos internally:
2752
+ // This attribute is used for :
2753
+ // → selection piscine's graph UI: startDay is not used directly in the graph but to calculate the week attribute, to display each object - quest, raid or exam - on the corresponding branch/week it's happening
2754
+ // → calendar view UI: display the duration of each object over the week (but currently the component is not in use)
2755
+ // → stats: check if the object has begun inside the event to extract its stats\n→ object's attributes: calculation of the attributes scopeEnd, delay & week",
2756
+ })
2757
+ relationAttrs.startDay = {
2758
+ piscine: {
2759
+ exam: {
2760
+ ...sharedStartDay,
2761
+ ...Functions({
2762
+ 'from end of previous content temporal-window': getExamStartDay,
2763
+ }),
2764
+ },
2765
+ quest: sharedStartDay,
2766
+ raid: sharedStartDay,
2767
+ project: sharedStartDay,
2768
+ },
2769
+ }
2770
+
2771
+ const getProgressStatus = progress => {
2772
+ if (progress?.isDone) {
2773
+ return progress.grade >= 1 ? 'succeeded' : 'failed'
2774
+ }
2775
+ }
2776
+
2777
+ const examExerciseStatus = object => {
2778
+ const { prev, attrs, progress, parent, index, event } = object
2779
+ const progressStatus = getProgressStatus(progress)
2780
+ if (progressStatus) return progressStatus
2781
+ if (!parent) return 'available'
2782
+ if (parent.attrs.status === 'blocked') return 'blocked'
2783
+
2784
+ const { group } = attrs
2785
+ if (group <= 1) return parent.attrs.status
2786
+ // check for previous exercises in the exam
2787
+ // groups could be skipped due to potential misconfiguration,
2788
+ // so we search for the closest previous group relative to the current group
2789
+ const previousGroups = _children(parent).filter(c => c.attrs.group < group)
2790
+ const maxGroup = Math.max(...previousGroups.map(c => c.attrs.group))
2791
+ const prevValidated = previousGroups
2792
+ .filter(c => c.attrs.group === maxGroup)
2793
+ .some(c => c.attrs.status === 'succeeded')
2794
+
2795
+ return prevValidated ? 'available' : 'blocked'
2796
+ }
2797
+ const questExerciseStatus = object => {
2798
+ const { prev, attrs, progress, parent, index, event } = object
2799
+ const progressStatus = getProgressStatus(progress)
2800
+ if (progressStatus) return progressStatus
2801
+ if (!parent) return 'available'
2802
+ if (parent.attrs.status === 'blocked') return 'blocked'
2803
+
2804
+ const now = Date.now()
2805
+ if (attrs.special) {
2806
+ if (now < attrs.start) return 'blocked'
2807
+ if (now > attrs.duration + attrs.start) return 'failed'
2808
+ return 'available'
2809
+ }
2810
+
2811
+ if (prev && prev.attrs.status !== 'succeeded') return 'blocked'
2812
+ const tomorrow = Date.now() + DAY
2813
+ if (
2814
+ parent.prev?.type === 'project' &&
2815
+ parent.prev?.attrs.status !== 'succeeded' &&
2816
+ parent.prev?.attrs.scopeEnd > tomorrow
2817
+ ) {
2818
+ return 'blocked'
2819
+ }
2820
+
2821
+ const prevQuest = findPrev(parent.prev, isQuest)
2822
+ if (
2823
+ prevQuest &&
2824
+ prevQuest.attrs.status !== 'succeeded' &&
2825
+ prevQuest.attrs.scopeEnd > tomorrow
2826
+ ) {
2827
+ return 'blocked'
2828
+ }
2829
+
2830
+ return 'available'
2831
+ }
2832
+
2833
+ /**
2834
+ * @throws This function throws an error if any of the required objects is invalid
2835
+ * @returns {bool} true if all the required objects are succeeded, false otherwise
2836
+ * @params {any} A 01-object
2837
+ */
2838
+ const hasSucceededRequiredObjects = (requirements, object) => {
2839
+ if (!requirements) return true
2840
+ const { core, objects } = requirements
2841
+ if ((!objects || !objects.length) && !core) return true
2842
+
2843
+ // aggregate core object and regular required objects in the list of objects to check
2844
+ const requiredObjects = [...(objects || []), core].filter(Boolean) // only keep values that are not undefined
2845
+ let objectFromPath
2846
+ return requiredObjects.every(relativePath => {
2847
+ try {
2848
+ objectFromPath = getObjectFromRelativePath(relativePath, object)
2849
+ } catch {
2850
+ // consider the requirement locked if an error is thrown by getObjectFromRelativePath
2851
+ return false
2852
+ }
2853
+ return objectFromPath?.attrs.status === 'succeeded'
2854
+ })
2855
+ }
2856
+
2857
+ // check if the requirements are met for a given object
2858
+ const meetsRequirements = ({ requirements, object, user, progress }) => {
2859
+ // check if there's already a progress, to not block students who began the project before implementing the requirements feature
2860
+ if ((progress && Object.keys(progress).length) || !requirements) return true
2861
+
2862
+ // check if the required skills have been earned & the required objects have been succeeded
2863
+ const hasSkills = hasRequiredSkills(requirements.skills, user.skills)
2864
+ const hasSucceededObjects = hasSucceededRequiredObjects(requirements, object)
2865
+
2866
+ return hasSucceededObjects && hasSkills
2867
+ }
2868
+
2869
+ const projectInPiscineStatus = object => {
2870
+ const { prev, parent, attrs } = object
2871
+ if (!parent.progress) return 'blocked'
2872
+ if (!attrs.hasStarted) return 'blocked'
2873
+ if (Date.now() < attrs.scopeEnd && notSucceeded(prev)) return 'blocked'
2874
+
2875
+ return 'available'
2876
+ }
2877
+
2878
+ const projectStatus = (object, user) => {
2879
+ const { attrs, progress } = object
2880
+ const { requirements, parentType } = attrs
2881
+
2882
+ // check first for existing progress
2883
+ const progressStatus = getProgressStatus(progress)
2884
+ if (progressStatus) return progressStatus
2885
+
2886
+ // check if the object requirements are met
2887
+ if (!meetsRequirements({ object, requirements, user, progress })) {
2888
+ return 'blocked'
2889
+ }
2890
+
2891
+ // handle special case for project in piscine
2892
+ if (parentType === 'piscine') return projectInPiscineStatus(object)
2893
+ return 'available'
2894
+ }
2895
+
2896
+ const notSucceeded = quest =>
2897
+ quest?.attrs.inScope && quest?.attrs.status !== 'succeeded'
2898
+ const successStatus = children => {
2899
+ for (const child of children) {
2900
+ if (!isRequired(child)) continue
2901
+ if (!child.progress) return 'failed'
2902
+ const status = getProgressStatus(child.progress)
2903
+ if (status && status !== 'succeeded') return status
2904
+ }
2905
+ return 'succeeded'
2906
+ }
2907
+
2908
+ const questStatus = object => {
2909
+ const { prev, parent, attrs, progress } = object
2910
+ if (!parent) return 'blocked'
2911
+ if (!parent.progress) return 'blocked'
2912
+ if (!attrs.hasStarted) return 'blocked'
2913
+ if (
2914
+ !attrs.special &&
2915
+ Date.now() > attrs.scopeEnd &&
2916
+ notSucceeded(findPrev(prev, isQuest))
2917
+ ) {
2918
+ return 'blocked'
2919
+ }
2920
+
2921
+ const children = Object.values(object.children || {})
2922
+ return children.length
2923
+ ? successStatus(children)
2924
+ : getProgressStatus(progress) || 'available'
2925
+ }
2926
+
2927
+ const getPiscineStatus = (object, user) => {
2928
+ const { progress, event, attrs } = object
2929
+ const { requirements } = attrs
2930
+ const progressStatus = getProgressStatus(progress)
2931
+ if (progressStatus) return progressStatus
2932
+
2933
+ if (!event) return 'blocked'
2934
+ const registrationNotStarted = Date.now() < event.registrationStartAt
2935
+ if (
2936
+ !meetsRequirements({ object, requirements, user, progress }) ||
2937
+ registrationNotStarted
2938
+ ) {
2939
+ return 'blocked'
2940
+ }
2941
+
2942
+ return 'available'
2943
+ }
2944
+
2945
+ const raidStatus = object => {
2946
+ const { progress, event, attrs } = object
2947
+ const progressStatus = getProgressStatus(progress)
2948
+ if (progressStatus) return progressStatus
2949
+
2950
+ if (!event) return 'blocked'
2951
+ const now = Date.now()
2952
+ if (now < attrs.start) return 'blocked'
2953
+ // ! for some reason we are having problems setting the raid status blocked or failed
2954
+ // ! when a user does not register to the raid it should render a view saying that he did not register in time
2955
+ // ! but this is not the case here
2956
+ // TODO : ^ fix the issue with the non registered users and the raid view
2957
+ return 'available'
2958
+ }
2959
+
2960
+ const alwaysOpen = () => 'available'
2961
+ const isWaitingEndOfEvent = (type, progress) =>
2962
+ progress?.event &&
2963
+ (type === 'interview' || type === 'piscine-registration') &&
2964
+ new Date(progress.event.endAt) > Date.now()
2965
+
2966
+ const onboardingStatus = object => {
2967
+ const hasRelatedObject = object.type.endsWith('-registration')
2968
+ const relatedObjectName = object.type.split('-')[0]
2969
+ const relatedObject = hasRelatedObject
2970
+ ? getChildByType(object.parent.parent.children, relatedObjectName)
2971
+ : object
2972
+
2973
+ if (!relatedObject) return 'blocked'
2974
+ const progress = relatedObject.lastProgress
2975
+ const progressStatus = getProgressStatus(progress)
2976
+ if (
2977
+ progressStatus === 'succeeded' &&
2978
+ isWaitingEndOfEvent(object.type, relatedObject.lastProgress)
2979
+ ) {
2980
+ return 'available'
2981
+ }
2982
+ if (progressStatus) return progressStatus
2983
+ if (
2984
+ !object.prev ||
2985
+ (object.prev && object.prev.attrs.status === 'succeeded')
2986
+ ) {
2987
+ return 'available'
2988
+ }
2989
+ return 'blocked'
2990
+ }
2991
+ const sharedOnboardingStepStatus = {
2992
+ type: 'string',
2993
+ label: 'Unlock system',
2994
+ ...Functions({ 'previous step validated': onboardingStatus }), // old version on onboarding: step by step selection
2995
+ options: ['blocked', 'available', 'succeeded', 'failed'],
2996
+ required: true,
2997
+ }
2998
+ const sharedStatus = {
2999
+ type: 'string',
3000
+ label: 'Unlock system and result status',
3001
+ required: true,
3002
+ options: ['blocked', 'available', 'succeeded', 'failed'],
3003
+ }
3004
+ relationAttrs.status = {
3005
+ campus: {
3006
+ // TODO: could be rm for onboarding?
3007
+ onboarding: {
3008
+ type: 'string',
3009
+ private: true,
3010
+ required: true,
3011
+ ...Functions({ 'always open': alwaysOpen }),
3012
+ },
3013
+ piscine: {
3014
+ ...sharedStatus,
3015
+ ...Functions({ 'by requirements and event': getPiscineStatus }),
3016
+ },
3017
+ },
3018
+ onboarding: {
3019
+ administration: sharedOnboardingStepStatus,
3020
+ games: sharedOnboardingStepStatus,
3021
+ 'piscine-registration': sharedOnboardingStepStatus,
3022
+ 'module-registration': sharedOnboardingStepStatus,
3023
+ interview: sharedOnboardingStepStatus,
3024
+ },
3025
+ piscine: {
3026
+ exam: {
3027
+ ...sharedStatus,
3028
+ ...Functions({ 'by event': questStatus }),
3029
+ },
3030
+ quest: {
3031
+ ...sharedStatus,
3032
+ ...Functions({ 'by scope': questStatus }),
3033
+ },
3034
+ raid: {
3035
+ ...sharedStatus,
3036
+ ...Functions({ 'by event': raidStatus }),
3037
+ },
3038
+ project: {
3039
+ ...sharedStatus,
3040
+ editable: true,
3041
+ editableDefaultValue: 'available',
3042
+ ...Functions({ 'by requirements': projectStatus }),
3043
+ },
3044
+ },
3045
+ quest: {
3046
+ exercise: {
3047
+ ...sharedStatus,
3048
+ editable: true,
3049
+ editableDefaultValue: 'available',
3050
+ ...Functions({ 'one by one': questExerciseStatus }),
3051
+ },
3052
+ },
3053
+ exam: {
3054
+ exercise: {
3055
+ ...sharedStatus,
3056
+ editable: true,
3057
+ editableDefaultValue: 'available',
3058
+ ...Functions({
3059
+ 'one by one (grouped by difficulty)': examExerciseStatus,
3060
+ }),
3061
+ },
3062
+ },
3063
+ module: {
3064
+ project: {
3065
+ ...sharedStatus,
3066
+ editable: true,
3067
+ editableDefaultValue: 'available',
3068
+ ...Functions({ 'by requirements': projectStatus }),
3069
+ },
3070
+ piscine: {
3071
+ ...sharedStatus,
3072
+ ...Functions({ 'by requirements and event': getPiscineStatus }),
3073
+ },
3074
+ exam: {
3075
+ ...sharedStatus,
3076
+ ...Functions({ 'by event': questStatus }),
3077
+ },
3078
+ },
3079
+ }
3080
+
3081
+ const getSubject = object => {
3082
+ const path = `subjects/${getObjectPath(object)}/README.md`
3083
+ return `/markdown/root/public/${path}`
3084
+ }
3085
+ const sharedSubject = Literal('', {
3086
+ label: 'Subject URL',
3087
+ editable: true,
3088
+ required: true,
3089
+ ...Functions({ 'README.md in subject folder': getSubject }),
3090
+ // TODO: add a check? can return a relative path so it's complicated
3091
+ })
3092
+ attrs.subject = {
3093
+ exercise: sharedSubject,
3094
+ project: sharedSubject,
3095
+ raid: sharedSubject,
3096
+ }
3097
+
3098
+ const sharedText = Literal('', {
3099
+ editable: true,
3100
+ ...translatable,
3101
+ })
3102
+ // TODO: rename it documentToSign, or something more specific than 'text'
3103
+ attrs.text = {
3104
+ 'upload-step': {
3105
+ ...sharedText,
3106
+ label: 'Resume', // TODO: mv it to attrs.resume
3107
+ },
3108
+ 'sign-step': {
3109
+ ...sharedText,
3110
+ label: 'Text to agree to',
3111
+ required: true,
3112
+ },
3113
+ 'contact-validation-step': {
3114
+ ...sharedText,
3115
+ label: 'Resume', // TODO: mv it to attrs.resume
3116
+ },
3117
+ }
3118
+
3119
+ types.timelineChunk = TypeObject({
3120
+ label: 'Month guideline',
3121
+ type: {
3122
+ month: Literal(120, {
3123
+ label: 'Month',
3124
+ editable: true,
3125
+ required: true,
3126
+ primary: true,
3127
+ // to put 120 first in options - could also be [120, ...arrayOf(119, 1)]
3128
+ options: arrayOf(120, 1).reverse(),
3129
+ check: (month, object) => {
3130
+ checkIntegerInBetween(month, 1, 120)
3131
+
3132
+ const definitionsWithSameMonth = object.attrs.timeline.filter(
3133
+ guideline => guideline.month === month,
3134
+ )
3135
+ if (definitionsWithSameMonth?.length > 1) {
3136
+ throw Error(
3137
+ `Month ${month} is set for different timeline guidelines! A given month can only be defined once.`,
3138
+ )
3139
+ }
3140
+ },
3141
+ }),
3142
+ minLevel: Literal(0, {
3143
+ label: 'Minimum level to be considered on track',
3144
+ editable: true,
3145
+ required: true,
3146
+ options: arrayOf(MAX_LEVEL, 0),
3147
+ check: level => checkIntegerInBetween(level, 0, MAX_LEVEL),
3148
+ }),
3149
+ expectedLevel: Literal(0, {
3150
+ label: 'Expected level',
3151
+ editable: true,
3152
+ required: true,
3153
+ options: arrayOf(MAX_LEVEL, 0),
3154
+ check: level => checkIntegerInBetween(level, 0, MAX_LEVEL),
3155
+ }),
3156
+ checkpointLevel: Literal(0, {
3157
+ label: 'Expected checkpoint level',
3158
+ editable: true,
3159
+ required: true,
3160
+ options: arrayOf(100, 0),
3161
+ check: level => checkIntegerInBetween(level, 0, 100),
3162
+ }),
3163
+ rank: Literal('', {
3164
+ label: 'Expected rank',
3165
+ editable: true,
3166
+ check: (rank, object) => {
3167
+ if (!object.attrs.ranksDefinitions?.length) {
3168
+ throw Error(
3169
+ `Must match an existing rank name, but ranks definitions are not set.`,
3170
+ )
3171
+ }
3172
+
3173
+ const ranksNames = object.attrs.ranksDefinitions.map(({ name }) => name)
3174
+ if (!ranksNames.includes(rank)) {
3175
+ throw Error(
3176
+ `Must match one of the following existing rank names: ${ranksNames.join(
3177
+ ', ',
3178
+ )}.`,
3179
+ )
3180
+ }
3181
+ },
3182
+ }),
3183
+ developpedSkills: TypeObject({
3184
+ label: 'Developed skills on a specific months duration',
3185
+ editable: true,
3186
+ type: {
3187
+ monthsSpan: Literal(1, {
3188
+ label: 'Months duration',
3189
+ editable: true,
3190
+ options: arrayOf(120, 1),
3191
+ check: month => checkIntegerInBetween(month, 1, 120),
3192
+ }),
3193
+ list: {
3194
+ label: 'List of developed skills', // shouldn't it be skillsList?
3195
+ editable: true,
3196
+ required: true,
3197
+ value: [''],
3198
+ type: [
3199
+ Literal('', {
3200
+ label: 'Skill developed',
3201
+ editable: true,
3202
+ required: true,
3203
+ // check: value => checkNotEmpty(value), // cannot check not empty if def value is empty
3204
+ }),
3205
+ ],
3206
+ check: list => {
3207
+ if (!list?.length || !Array.isArray(list)) {
3208
+ throw Error('Must be a non empty array')
3209
+ }
3210
+ },
3211
+ },
3212
+ },
3213
+ }),
3214
+ },
3215
+ })
3216
+ const sharedTimeline = {
3217
+ label: 'Timeline per month for a curriculum',
3218
+ value: (...args) => [
3219
+ mapValues(types.timelineChunk.type, subDef =>
3220
+ getDefaultValue(subDef, ...args),
3221
+ ),
3222
+ ],
3223
+ editable: true,
3224
+ restrictive: true,
3225
+ type: [types.timelineChunk],
3226
+ check: timeline => {
3227
+ if (!timeline?.length || !Array.isArray(timeline)) {
3228
+ throw Error('Must be a non empty array')
3229
+ }
3230
+ },
3231
+ }
3232
+ attrs.timeline = {
3233
+ module: sharedTimeline,
3234
+ piscine: sharedTimeline,
3235
+ }
3236
+
3237
+ // TODO: resolve how to show this in front: as the only key is private, object is empty
3238
+ types.adminSelectionValidation = TypeObject({
3239
+ type: {
3240
+ type: Literal('admin_selection', {
3241
+ required: true,
3242
+ private: true,
3243
+ primary: true,
3244
+ label: 'Type',
3245
+ }),
3246
+ },
3247
+ label: 'Admin selection evaluation',
3248
+ })
3249
+
3250
+ const getTesterImage = ({ attrs }) =>
3251
+ attrs.language && `ghcr.io/01-edu/test-${attrs.language}`
3252
+ types.testerValidation = TypeObject({
3253
+ label: 'Tester evaluation',
3254
+ type: {
3255
+ type: Literal('tester', { required: true, private: true, label: 'Type' }),
3256
+ testImage: {
3257
+ type: 'string',
3258
+ label: 'Docker image',
3259
+ required: true,
3260
+ editable: true,
3261
+ primary: true,
3262
+ instruction: 'used to run the tests',
3263
+ // description currently in progress of writing
3264
+ ...Functions({ 'from language': getTesterImage }),
3265
+ },
3266
+ // TODO: mv to relation attribute? Used in the attributes of an exercise or in the children attrs of a quest.
3267
+ cooldown: Literal(3 * MIN, {
3268
+ required: true,
3269
+ editable: true,
3270
+ label: 'Delay between two submit',
3271
+ }),
3272
+ },
3273
+ })
3274
+
3275
+ // should not be editable because it could break the audits query system
3276
+ const sharedMatchWhere = {
3277
+ type: 'object',
3278
+ required: true,
3279
+ label: 'Audited by',
3280
+ instruction: 'Match groups with auditors',
3281
+ }
3282
+ // matchWhere for user_audit validations
3283
+ // NB: user 1 should always be excluded because he has admin role that can't be removed
3284
+ const userInCampus = ({ attrs }) => ({
3285
+ campus: { _eq: attrs.campus },
3286
+ _not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
3287
+ })
3288
+ const userInEvent = ({ attrs }) => ({
3289
+ ...userInCampus({ attrs }),
3290
+ events: { eventId: { _eq: attrs.eventId } },
3291
+ })
3292
+
3293
+ // fallback on user 01 when no campus admin/dedicated auditor defined
3294
+ const isUser01 = { id: { _eq: 1 } }
3295
+ const userAuditorForEvent = ({ event }) => {
3296
+ const labelName = `auditorFor${event?.id}`
3297
+ const hasEventLabel = { labels: { label: { name: { _eq: labelName } } } }
3298
+
3299
+ return { _or: [hasEventLabel, isUser01] }
3300
+ }
3301
+
3302
+ // matchWhere for admin_audit validations
3303
+ const isCampusAdmin = ({ attrs }) => ({
3304
+ _or: [
3305
+ { private: { roles: { slug: { _eq: `campus_admin_${attrs.campus}` } } } },
3306
+ isUser01,
3307
+ ],
3308
+ })
3309
+ // if school don't want any specific attribution,
3310
+ // we attribute audits by default to user 01-edu, that cannot be removed,
3311
+ // cannot loose his admin role, or have any record associated (sql fn triggers)
3312
+ const noAttribution = () => isUser01
3313
+
3314
+ export const getAuditPath = object => {
3315
+ const path = `subjects/${getObjectPath(object)}/audit/README.md`
3316
+ return `/markdown/root/public/${path}`
3317
+ }
3318
+ const sharedTypesForm = {
3319
+ required: true,
3320
+ editable: true,
3321
+ type: 'string',
3322
+ label: 'Audit form url',
3323
+ instruction: 'List of questions asked by the auditor during the audit.',
3324
+ ...Functions({ 'README in audit folder': getAuditPath }),
3325
+ }
3326
+ types.adminAuditValidationDelay = Literal(0, {
3327
+ label: 'Delay',
3328
+ editable: true,
3329
+ required: true,
3330
+ instruction:
3331
+ 'Time allowed to complete the audit before it expires. "0s" equals no delay.', // TODO: convert to MS'
3332
+ })
3333
+ types.adminAuditValidationRequired = Literal(1, {
3334
+ label: 'Number required',
3335
+ required: true,
3336
+ instruction: 'Minimum of audits required',
3337
+ check: value => {
3338
+ if (value < 1) throw Error('must be at least one')
3339
+ },
3340
+ })
3341
+ types.adminAuditValidationRatio = Literal(1, {
3342
+ label: 'Ratio',
3343
+ required: true,
3344
+ instruction: 'Proportion of extra audits generated',
3345
+ check: value => {
3346
+ if (value < 1) throw Error('must be at least one')
3347
+ },
3348
+ })
3349
+
3350
+ types.adminAuditValidation = TypeObject({
3351
+ label: 'Admin audit evaluation',
3352
+ type: {
3353
+ type: Literal('admin_audit', {
3354
+ required: true,
3355
+ private: true,
3356
+ primary: true,
3357
+ label: 'Type',
3358
+ }),
3359
+ delay: types.adminAuditValidationDelay,
3360
+ required: types.adminAuditValidationRequired,
3361
+ ratio: types.adminAuditValidationRatio,
3362
+ matchWhere: {
3363
+ ...sharedMatchWhere,
3364
+ ...Functions({
3365
+ 'an admin of same campus': isCampusAdmin,
3366
+ 'manual attribution': noAttribution,
3367
+ }),
3368
+ },
3369
+ form: sharedTypesForm,
3370
+ },
3371
+ })
3372
+
3373
+ types.userAuditValidationDelay = Literal((2 * WEEK) / MIN, {
3374
+ label: 'Delay',
3375
+ required: true,
3376
+ editable: true,
3377
+ instruction: 'Time allowed to complete the audit before it expires', // TODO: convert to MS
3378
+ })
3379
+ types.userAuditValidationRequired = Literal(5, {
3380
+ label: 'Number required',
3381
+ required: true,
3382
+ editable: true,
3383
+ instruction: 'Minimum of audits required',
3384
+ check: value => {
3385
+ if (value < 1) throw Error('must be at least one')
3386
+ },
3387
+ })
3388
+ types.userAuditValidationRatio = Literal(2, {
3389
+ label: 'Ratio',
3390
+ required: true,
3391
+ editable: true,
3392
+ instruction: 'Proportion of extra audits generated',
3393
+ check: value => {
3394
+ if (value < 1) throw Error('must be at least one')
3395
+ },
3396
+ })
3397
+
3398
+ types.userAuditValidation = TypeObject({
3399
+ label: 'User audit evaluation',
3400
+ type: {
3401
+ type: Literal('user_audit', {
3402
+ required: true,
3403
+ private: true,
3404
+ primary: true,
3405
+ label: 'Type',
3406
+ }),
3407
+ delay: types.userAuditValidationDelay,
3408
+ required: types.userAuditValidationRequired,
3409
+ ratio: types.userAuditValidationRatio,
3410
+ matchWhere: {
3411
+ ...sharedMatchWhere,
3412
+ ...Functions({
3413
+ 'any user in same campus': userInCampus,
3414
+ 'any user in same event': userInEvent,
3415
+ }),
3416
+ },
3417
+ form: sharedTypesForm,
3418
+ preQuestions: {
3419
+ editable: true,
3420
+ type: 'array',
3421
+ label: 'Pre questions',
3422
+ instruction: 'List of questions that appears before the main audit',
3423
+ },
3424
+ postQuestions: {
3425
+ editable: true,
3426
+ type: 'array',
3427
+ label: 'Post questions',
3428
+ instruction: 'List of questions that appears after the main audit',
3429
+ },
3430
+ },
3431
+ })
3432
+
3433
+ types.raidAuditorValidation = TypeObject({
3434
+ label: 'User audit evaluation (dedicated for event)',
3435
+ type: {
3436
+ type: Literal('dedicated_auditors_for_event', {
3437
+ required: true,
3438
+ private: true,
3439
+ primary: true,
3440
+ label: 'Type',
3441
+ }),
3442
+ delay: types.adminAuditValidationDelay, // no limit to audit?
3443
+ required: types.adminAuditValidationRequired,
3444
+ ratio: types.adminAuditValidationRatio,
3445
+ matchWhere: {
3446
+ ...sharedMatchWhere,
3447
+ ...Functions({
3448
+ "any user labelled as event's auditor ": userAuditorForEvent,
3449
+ }),
3450
+ },
3451
+ form: sharedTypesForm,
3452
+ },
3453
+ })
3454
+
3455
+ const sharedValidations = {
3456
+ label: 'Evaluated by',
3457
+ instruction: 'List of evaluations to complete in order',
3458
+ check: value => {
3459
+ if (!value.length) throw Error('must have at least one element')
3460
+ },
3461
+ required: true,
3462
+ editable: true,
3463
+ }
3464
+ attrs.validations = {
3465
+ project: {
3466
+ ...sharedValidations,
3467
+ type: [types.userAuditValidation], // TODO: add types.adminAuditValidation? test if would work
3468
+ value: (...args) => [
3469
+ mapValues(types.userAuditValidation.type, subDef =>
3470
+ getDefaultValue(subDef, ...args),
3471
+ ),
3472
+ ],
3473
+ },
3474
+ raid: {
3475
+ ...sharedValidations,
3476
+ type: [types.adminAuditValidation, types.raidAuditorValidation],
3477
+ maxElements: 1,
3478
+ value: (...args) => [
3479
+ mapValues(types.adminAuditValidation.type, subDef =>
3480
+ getDefaultValue(subDef, ...args),
3481
+ ),
3482
+ ],
3483
+ },
3484
+ exercise: {
3485
+ ...sharedValidations,
3486
+ type: [types.testerValidation],
3487
+ value: (...args) => [
3488
+ mapValues(types.testerValidation.type, subDef =>
3489
+ getDefaultValue(subDef, ...args),
3490
+ ),
3491
+ ],
3492
+ },
3493
+ piscine: {
3494
+ ...sharedValidations,
3495
+ type: [types.adminSelectionValidation],
3496
+ value: (...args) => [
3497
+ mapValues(types.adminSelectionValidation.type, subDef =>
3498
+ getDefaultValue(subDef, ...args),
3499
+ ),
3500
+ ],
3501
+ },
3502
+ interview: {
3503
+ ...sharedValidations,
3504
+ type: [types.adminSelectionValidation],
3505
+ value: (...args) => [
3506
+ mapValues(types.adminSelectionValidation.type, subDef =>
3507
+ getDefaultValue(subDef, ...args),
3508
+ ),
3509
+ ],
3510
+ },
3511
+ }
3512
+
3513
+ attrs.videos = {
3514
+ quest: Literal('https://www.youtube.com/', {
3515
+ type: 'string',
3516
+ label: 'Videos URL',
3517
+ editable: true,
3518
+ check: checkValidURL,
3519
+ }),
3520
+ }
3521
+
3522
+ const getWeek = ({ attrs }) => {
3523
+ if (!attrs.startDay) return undefined // for exams in module for example
3524
+ const diff = attrs.startDay / 7
3525
+ return Math.ceil(diff)
3526
+ }
3527
+ // TODO: this should be defined in the relation
3528
+ const sharedWeek = Literal(1, {
3529
+ label: 'Week n°',
3530
+ required: true,
3531
+ private: true, // would be interesting to show only in an event context
3532
+ // UI only, represents the number of the week inside an event.
3533
+ // This attribute is used for :
3534
+ // → selection piscine's graph UI : position each object on the corresponding branch (one branch represents one week)
3535
+ // → calendar view UI: position each object on the calendar's weeks (but currently the component is not in use)
3536
+ ...Functions({ 'from day n°': getWeek }),
3537
+ })
3538
+ relationAttrs.week = {
3539
+ piscine: {
3540
+ exam: sharedWeek,
3541
+ quest: sharedWeek,
3542
+ raid: sharedWeek,
3543
+ project: {
3544
+ ...sharedWeek,
3545
+ value: 1,
3546
+ functions: undefined,
3547
+ required: false,
3548
+ private: false,
3549
+ editable: true,
3550
+ },
3551
+ },
3552
+ }
3553
+
3554
+ const sharedZeroXpIndex = Literal(0, {
3555
+ required: true,
3556
+ private: true,
3557
+ })
3558
+ const parentXpIndex = ({ parent }) => parent.attrs.xpIndex
3559
+ const getXpIndex = ({ id, name, prev, parent, attrs, type }) => {
3560
+ if (prev) return (prev.attrs.xpIndex || 0) + (_difficultyXpCoef(prev) || 1)
3561
+ return parent?.attrs.xpIndex || 0
3562
+ }
3563
+ const sharedXpIndex = {
3564
+ required: true,
3565
+ private: true,
3566
+ type: 'number',
3567
+ ...Functions({
3568
+ 'from previous xpIndex and XP coefficient (or parent if exam or no prev)':
3569
+ getXpIndex,
3570
+ }),
3571
+ }
3572
+ relationAttrs.xpIndex = {
3573
+ campus: {
3574
+ module: sharedZeroXpIndex,
3575
+ piscine: sharedZeroXpIndex,
3576
+ },
3577
+ module: {
3578
+ piscine: sharedZeroXpIndex,
3579
+ project: sharedXpIndex,
3580
+ exam: sharedXpIndex,
3581
+ },
3582
+ piscine: {
3583
+ exam: sharedXpIndex,
3584
+ project: sharedXpIndex,
3585
+ quest: sharedXpIndex,
3586
+ raid: sharedXpIndex,
3587
+ },
3588
+ quest: { exercise: sharedXpIndex },
3589
+ exam: {
3590
+ exercise: {
3591
+ required: true,
3592
+ private: true,
3593
+ type: 'number',
3594
+ ...Functions({ 'from parent xpIndex': parentXpIndex }),
3595
+ },
3596
+ },
3597
+ }
3598
+
3599
+ export const internal = {
3600
+ hasSucceededRequiredObjects,
3601
+ projectStatus,
3602
+ getPiscineStatus,
3603
+ meetsRequirements,
3604
+ }