diff --git a/.infra/.env_server b/.infra/.env_server
index 95c16772e9..7162d67efa 100644
--- a/.infra/.env_server
+++ b/.infra/.env_server
@@ -7,8 +7,8 @@ LBA_LOG_LEVEL=info
LBA_LOG_FORMAT={{ vault[env_type].LBA_LOG_FORMAT }}
LBA_LOG_DESTINATIONS={{ vault[env_type].LBA_LOG_DESTINATIONS }}
LBA_LOG_TYPE=console
-LBA_SLACK_WEBHOOK_URL={{ vault.LBA_SLACK_WEBHOOK_URL }}
-LBA_JOB_SLACK_WEBHOOK={{ vault.LBA_JOB_SLACK_WEBHOOK }}
+LBA_SLACK_WEBHOOK_URL={{ vault[env_type].LBA_SLACK_WEBHOOK_URL }}
+LBA_JOB_SLACK_WEBHOOK={{ vault[env_type].LBA_JOB_SLACK_WEBHOOK }}
LBA_MONGODB_URI={{ vault[env_type].LBA_MONGODB_URI }}
LBA_CATALOGUE_URL=https://catalogue-apprentissage.intercariforef.org
LBA_SERVER_SENTRY_DSN={{ vault.LBA_SERVER_SENTRY_DSN }}
@@ -50,3 +50,4 @@ LBA_S3_BUCKET={{ vault.LBA_S3_BUCKET }}
LBA_ENTREPRISE_API_KEY={{ vault.LBA_ENTREPRISE_API_KEY }}
LBA_FRANCE_COMPETENCE_API_KEY={{ vault.LBA_FRANCE_COMPETENCE_API_KEY }}
LBA_FRANCE_COMPETENCE_TOKEN={{ vault.LBA_FRANCE_COMPETENCE_TOKEN }}
+LBA_API_APPRENTISSAGE_KEY={{ vault.LBA_API_APPRENTISSAGE_KEY }}
diff --git a/.infra/ansible/deploy.yml b/.infra/ansible/deploy.yml
index b0466977c4..ed1e6edf16 100644
--- a/.infra/ansible/deploy.yml
+++ b/.infra/ansible/deploy.yml
@@ -7,16 +7,10 @@
tasks:
- include_tasks: ./tasks/files_copy.yml
- - name: Création du docker-compose.yml {{env_type}}
- shell:
- chdir: /opt/app
- cmd: 'sudo docker compose $(for file in $(ls docker-compose.*.yml); do echo -n "-f $file "; done) config -o docker-compose.yml'
- register: docker_deploy_output
-
- name: Récupération des images docker
shell:
chdir: /opt/app
- cmd: "sudo docker compose pull"
+ cmd: "/opt/app/tools/docker-compose.sh pull"
- name: Récupération du status de la stack
shell:
@@ -109,12 +103,12 @@
- name: Add cron to renew pole-emploi cert
ansible.builtin.cron:
- name: "renew-certificate"
+ name: "renew-certificate-pe"
minute: "0"
hour: "2"
weekday: "1"
job: "bash /opt/app/tools/ssl/renew-certificate.sh {{ alias_dns_name }} >> /var/log/cron.log 2>&1; /opt/app/tools/monitoring/export-cron-status-prom.sh -c 'Renew certificate Alias' -v $?"
- when: env_type != "preview"
+ when: env_type == "production"
- name: "Setup de la Metabase"
shell:
diff --git a/.infra/docker-compose.production.yml b/.infra/docker-compose.production.yml
index 136a5af30f..71dc85ddbe 100644
--- a/.infra/docker-compose.production.yml
+++ b/.infra/docker-compose.production.yml
@@ -94,7 +94,7 @@ services:
metabase:
<<: *default
- image: metabase/metabase:v0.49.3
+ image: metabase/metabase:v0.49.5
deploy:
<<: *deploy-default
resources:
diff --git a/.infra/files/configs/mongodb/seed.gpg b/.infra/files/configs/mongodb/seed.gpg
index f3e7b1de97..2312df7445 100644
--- a/.infra/files/configs/mongodb/seed.gpg
+++ b/.infra/files/configs/mongodb/seed.gpg
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8b3b88b65cdfcf23777ac85fb4557947424a69ea64057f0f9476cdd0fe6cdbd7
-size 278818873
+oid sha256:402d2e9adb5e3d44c58a588e522c7bbae0cb6b13b86abc92d7c3537f460896d2
+size 185574351
diff --git a/.infra/files/configs/reverse_proxy/system/5xx.html b/.infra/files/configs/reverse_proxy/system/5xx.html
index f979b8275a..9236cf8880 100644
--- a/.infra/files/configs/reverse_proxy/system/5xx.html
+++ b/.infra/files/configs/reverse_proxy/system/5xx.html
@@ -111,7 +111,7 @@
Cette page est temporairement indisponible
-
+
diff --git a/.infra/files/scripts/cli.sh b/.infra/files/scripts/cli.sh
index f286141d40..3800364496 100755
--- a/.infra/files/scripts/cli.sh
+++ b/.infra/files/scripts/cli.sh
@@ -2,4 +2,4 @@
set -euo pipefail
#Needs to be run as sudo
-docker compose run --rm --no-deps server yarn cli "$@"
+/opt/app/tools/docker-compose.sh run --rm --no-deps server yarn cli "$@"
diff --git a/.infra/files/scripts/migrations-status.sh b/.infra/files/scripts/migrations-status.sh
index b16784484c..c418bd8d84 100755
--- a/.infra/files/scripts/migrations-status.sh
+++ b/.infra/files/scripts/migrations-status.sh
@@ -2,4 +2,4 @@
set -euo pipefail
#Needs to be run as sudo
-docker compose run --rm --no-deps server yarn cli migrations:status
+/opt/app/tools/docker-compose.sh run --rm --no-deps server yarn cli migrations:status
diff --git a/.infra/files/scripts/migrations-up.sh b/.infra/files/scripts/migrations-up.sh
index e5197c7f5a..f569d345d0 100755
--- a/.infra/files/scripts/migrations-up.sh
+++ b/.infra/files/scripts/migrations-up.sh
@@ -12,7 +12,7 @@ fi
run_migrations(){
echo "Application des migrations ..."
- docker compose run --rm --no-deps server yarn cli migrations:up 2>&1 | tee "$LOG_FILEPATH"
+ /opt/app/tools/docker-compose.sh run --rm --no-deps server yarn cli migrations:up 2>&1 | tee "$LOG_FILEPATH"
}
run_migrations
diff --git a/.infra/files/scripts/trigger_indexes_creation.sh b/.infra/files/scripts/trigger_indexes_creation.sh
new file mode 100644
index 0000000000..a9df613bcf
--- /dev/null
+++ b/.infra/files/scripts/trigger_indexes_creation.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+#Needs to be run as sudo
+
+readonly LOG_DIR="/var/log/data-jobs"
+
+if [ ! -d "$LOG_DIR" ]; then
+ sudo mkdir -p "$LOG_DIR"
+ sudo chown $(whoami):$(whoami) "$LOG_DIR"
+fi
+
+trigger_indexes_creation(){
+ echo "Création des index mongoDb ..."
+ /opt/app/tools/docker-compose.sh run --rm --no-deps server yarn cli indexes:recreate --queued
+}
+
+trigger_indexes_creation
diff --git a/.infra/vault/vault.yml b/.infra/vault/vault.yml
index fbadde0623..26e21cd338 100644
--- a/.infra/vault/vault.yml
+++ b/.infra/vault/vault.yml
@@ -1,580 +1,638 @@
$ANSIBLE_VAULT;1.1;AES256
-38373939636531396133643335613564653632386331376135663434363965343634393232366138
-3135313965356466613430393966393934356465353663380a333261613532386566643761306262
-66363837653261303839653766333335346634613432393865613436336465356634376337633534
-6562633366383431320a333064666437623562616233323538616438386131623536363138386634
-64663839373965353563323764333538616237343836636230333035306263373638323732396432
-38356336353962386462323564333836356638303565653732336538373965373939323639623666
-61323638633761313662623239646436373537393766646237393133306234633162333065353338
-38363231363932323638326134393464363263353433623934616266646431633961356137656238
-35643961343361663435663737376230623966383032623233613839623665653037373435326133
-38303162323165343438646564363037623834363033366563666230366637396161323738633736
-33646266613762663164383437323331303065356564636665363133356366323739643964393435
-34396132366231333533653639303830343961333661393663346431363038373834633035343462
-62616263333835663630643938373138373532313734626466633336306137323061343532353332
-61356434653538636263316564646665356261353262623135663462373334623966323363626336
-66616534303539313432653836663235633864623738616435653863313833396533306537356364
-64353639396137333562366461376163353436343430383434303763383061306363653530656331
-62663064643031623066356465323362386330626339313662643733653463373164343565323530
-33356130633463333635373034353433643565626463656461386630373238373233633863363732
-37336262373064613938653463353434646339616435383532363531323838356261386362616339
-31343836383633383634326665653337333464343165663830656663333631323932633032346263
-39386663666339363466356335653839643234663839313761316532316230633366306666643130
-34646566643037343965346532633730623361626262646466366365333461643335633535653833
-30623835333636616261363637356437666231386262636133333663663130303534356566326161
-38663934626536373235366666666561353365373039313633656465326531623535616132613764
-32646262386334383864303638656539393161323666396337323164393065376162366632323737
-35633438656665373335336339326431306530616138303339326539616138613334666437346463
-66373366336635366230393731663462383432363935663339346135303963616163383038333434
-64346365653265616639623164336236336535656134623838393961623437666637343833643630
-33356664323239303439366533316165333835353739633961616465356138343065316564383566
-33646263643139653936326330326662346635643865303730363531616437613063643830613732
-34313563326662303037623337636436663436643938313461626534343465613433656234356636
-34616233616239343036376437356164343330336337656230646638303232323132653839396135
-32653234393131663439353138623461383537336537646133613932313431313832306132646165
-32373265326538383135643364376461333135386535343731373962383863366139353438306362
-65326661336362656465313361313131323064303931316532643833316236386534356633346265
-64366335333462663938663162343332376662336531393366326364376235383966353364353862
-64663238643633366638396266333536386135386534396534323666626662653036326434393135
-39613264303266336233386634656539383236636134333863613336326662663238386433616139
-36653431383137303739303431313239383231653832316364373637363231663831626135393930
-30336437616261653938636337323439383163663563336165343235323831653961346361376665
-32353731646430313364613934343465393162616137633762353435373663353631663430623065
-31636637346231346439326466363036306135366266613862326266313339333061666235656539
-64633763336563613130343639353836616565323066323363323263626164646363656364323763
-61613365656462323666376137326230343931396536653836666666393632626132303637376163
-33356537386339363033623934326239356134333530643139396361333434633861616461633362
-38333838396331626133383531316131306662666239336161636235633764353337316535613362
-64343030643136353438316432343965393563376138623866326135623433636563333731323033
-33656662663137636633343735316635363466316335613631373965326131343339666463646464
-38376139363039363033306361353666376663323137306637326236363138333663326231663334
-39366439316630323736366161363936663638316639633662363764653936376237643636323130
-62626331623135383132366435366263376631353630643931316538633438323237663338383736
-36393630646462343931393461366231613639373333343037666231366566383566666539616330
-30333237616138656230336463343432393861666233613865616234303937336331666432393564
-35313736353538663839333130623137366533346530613231336236383333666461663737356461
-39653663396139396134393739303838626138316164623931313837313338363537393133366434
-34663833343162646638653565393365656266346337336163376463323864653564353665336639
-65353639653065356536353266373635353066656539393662656565373030623935313363613030
-63313231303065613834643837636566623336323138663238366132653863396261393764623262
-66663264656635333061336231333730396162383162383766393966333561653538623338343838
-66616539626433343132626662656439353161313030663934396634386465303966633961346430
-62396232353936396132356230383466326462613430353464643739663631616561366438626232
-61623963303663653238326337396132616235653935666431333530616438636164313439396439
-36653166383333383061353034373566376136663931343536356462323132363831326133383966
-33623433343230623134633439356639316537663838663837353061383138666632396461663863
-30333863353835626362386630666564613934616361396137313330393662343964363432356564
-36313965396462333034636562343336633937306635643665383165646466643036323839363932
-34633065396230373935633363366539343235363738383364343238336630636532366437386565
-35633735343131303033396339323930393739373562646639663765383663343664643266656130
-66326666316230373839623839303230636336303330363338623337613730646230346333386433
-61646637663931353130316663316434653032616339656266343834656665633831346632653632
-35333230646237663237663165346362316432303638623036333832343763303734643932633436
-66383534353934303666623132366631663230323236323335623063313063396130613462633236
-39333238333631323065633233646632376538636563646163616536323233366134356230646535
-65643163366564353463386465366331356539376431373936393632393839323733623430396234
-64616138633364633963633265373733373038646466373133333732383831383938383864303762
-30363038653732356134326133303337326162383735323463643034643538323734643864396662
-65656630646236623465323034623032663839316261653435303632396331363439666635653039
-36623632623166313263356534373835363239393736396165386239663466613436666439653765
-61663730376133653861383132393236633962613537356338653435626438643961333034383566
-33343665653365313737313831386264343764393839356132323735353937646666653437613462
-65336130323931623632623230386230663065353761343935313231633433623534383833616232
-38323637376436666462373164353236353366663237623732623065633033616430393935366662
-61316462393337383139366361373961353439383238373330663138623337316361663966643362
-38613961363536646233643136636163623939303830663636333132633438393563303731363265
-30383862376165343061656561373936316662353639393264343533356136353336666438663434
-66663335386263363461333138316137616332636638613534383834653332633363336462373961
-34356265386661613665613532393662616262356133343131306665623463376532376562616234
-61393564393461303339663735326462346334313062646564353265306461323832303639653032
-31633662663631323838613866383933636238316666343661353635333466376463663637306365
-61363731303133303733363262396239376563663134356335663136346562363161633735656531
-66653963363562623139666663613965303065636537343235636139623430356463303633616632
-66393265633230386661343466663239333561353338613831396636383738666664313036323863
-62666164666431393163373566333364623338376130353335343161386333383266336437306261
-66303863646239643964623231326438636336353336646134303630373965636463653637393535
-31313663386237336134393739366338663334613263643361363965353939626333383339356662
-64336537316266386563626464343934633232343430306339306566613661363532373762633832
-66623730373635633862663863356634613362643664336138633166643936366331336132663037
-63346164613732363266353163663632376138303538623461656664656536623436616430363166
-63656436656162646330663431626339386634346565393438343165326639616261303633333337
-30613531336131613263623066346562313065356263636532653736386662613330656531613664
-31623462313437393164656366663037376661613063623866636637373261326532636136323733
-35343037613263313565353334623331666535643665623865613339316531373036353438366230
-35623334303538393865623663663562656532313839613765363333623434306461313139666439
-35653730333462363732613464363465346165376133613031633539326164666632613863663735
-61343535623132623632643438333366323632386132316561656534633064626636613962393566
-34373738343661333664323564396634336231636137633437633862633937333362373566646339
-63373065316339336363623330613964323838326134313339646134306565626232663561336433
-37613164356261643665376262383431616130373034613634653064366365383033343739636136
-31626238363032613035623336376138353365326637363732353236353866393866303163626334
-61616535343837303464643334646336353531633833653338633736653934633032643931333537
-37323465316231323239353261636563623866646434633531333062623136343464643632363061
-65633863663139346539386433323731313332653231643734386161303038663434363737633566
-61346664356135346637323731356431663939656462333631643835396538393939626464383330
-62323663373262313632396130376461383436323264306566623934396561303465383638396164
-31303564336666326262346662643534333839386136336430393435303836386165333463323130
-34386662363536386133303964303635396237623336303536653236643832396464373333376263
-37353132613964393136326131393466323131306462643262393934613530386535356539616662
-66643435623036333934333432396465376438336666626137643538393536303437343365366164
-35343564333335393032353939343936626432646462666432326636653138303963396465346234
-61333133396564376563653934303530343537656265366263373966393335366430396538363863
-32313432636430316465383237623633626439363431663239356136343932316532633830323162
-39656535663432383333373230383932376465663833333235623262373935376230393462363634
-36616465643639393634666362643732393965363631616233363362336634306330656532623961
-32336530613165643761653066346430613333326139613730626130313965623365343732653435
-31363437316239313035623730366638663237396636376161623939376366383261393931306230
-64643065343530393466396162386437623834333764346566333733386335303932666432333930
-63393533366630363836656333666166336238663236633364616434626461313633613932353065
-38313565666561643264316336303131386465656462643939373562653334623064613234643534
-32393433393836393461343164653539326635653130393563373132623930623537326564353061
-39323330646530373635303734306535316665636233303731643331333261616334323762323437
-64356137666638316131396437623335616337653165316339623538323234616433373438373864
-39303930663366656336383835336264393963663134333665316461343637666664623032376335
-33333836656534653030613938353639313663656531643337666238613633396361616230616439
-33616438346633613334316434303635376137323430343365386639613632326466366336316636
-36306631613961653237336436353962343865656139613363343333363833363639636164346136
-30623665323834343633306337613739313664663233376361383464373866623236393338643262
-66346261316535343837386638383863376434303263303237376134306561356661613563353233
-61346234653861616361393239646139643836393562303061353137383861653333656438663930
-39306566363237313233333265356337353865316339666138376564623666383139333263393765
-62626165653365663561336464636661613831303731663164323966386663343064653532303934
-35626463633537636665336564663830633961343964323735353165323630393732306232306334
-62613135346463393236626637613335373463373739646136303837623937393330633039383164
-61373764616235663837656235646264306361643532643734373330373536333535303531643937
-37643066646239323732373139303763363163666463323439663135323665653036326162346134
-36623735363233633634316365356137626161376534383866636337393630616434393663346637
-38646538366537366235353331306238333564666566386430653163363866346261323963623134
-32373761373237646461663139316132303639366664383034623865303632396166356566653061
-64643834396661316434663334633934383536373837343765316332386166653862653233636233
-34626433323165623931326265303232616264363763303637326239613764313361623234313839
-32643430343465666538393662323033373334303961356536633738336165663534333562656139
-32613766636435376138663666353861353231393634653761633764373066643539636338646230
-66356437613030363862313163356334393836333534333532326264316565636630613262323063
-61343636313961336337396339656364643065333737393633343766333564663663636634666362
-36313565353765656437303433616466626566356336656365366332393537616166303863313239
-65343562373065373732393961373665306138303461666463613732386163393830663362633331
-37326566393533363235613366303163373633626661336263333332333861373061373731386138
-64316336393663303665393239366330613064303632643833366431643737653863363135613563
-36313663323061363631643363306262636361366133633139643233636238326636373438316437
-38383538346633626631376236376533373836386663333061386466333234623232363632653165
-65366566623138313536303964633864393932366463303634623430333737636237383638643230
-64316461316533343864646364333166363165356365396537343835313832393630343536653039
-32356238633264353936306432383039626261316436636339346364386166303465396363366335
-65656332316365383837323961636239633636303932646635363037646638633666626137633232
-64323264313631313965633238336139623563373236663364326632656565336639643833363932
-62386363666535663033646163323763343165663562653335376437653761366333663365656266
-35386537336366646532363037363931323637326338393561366637383764396530623838646434
-63613237313736373631303430303337323336333537356263353936636431626362623865663735
-37303834643432623161626665653839653430616331636132326562306139353434656237323033
-61666438333334623164376463313438356663353262333132613239346465353363363332643061
-34306266636435393736663335613563373331346363616465656532646535613233623036366133
-31636566383930623036333134386236653730336437343037346437653033313238336465653763
-33346264313364356265363566616662353666613835653065306533373861626333376135363462
-61633237633138373030656465383235656634626634383463303837386637386466626434373830
-37343139396165646630353465376638626334366434303361653162396465613439326137616238
-34353731613030393861646364353163613831336234626538346465323730336566636362303730
-35326132303731623633333132613736626536333663313261356564373739333666623031613262
-35643234643538363564333138393065613335343338643637336466336331653265326531303266
-36323062323236663333333631396462333466393861316335643261643135626462663865323066
-37386562613035633363623933343934383535653665663261656136643466303633333139303035
-61653764653432643430343032666661363166376534363639343463643236656437353530343933
-37326432616539616532313934653831363832383965653737393662616361366433316239613737
-38636261326361373033393034613162353062626461383366663262356638633736316662306136
-38396631303765323239343937393234656134646130333038353430356435353663383433346232
-66396561616337313431643139613863303438646239626338333736623335366632393361643030
-38613062303063656161613630303466653038393339376639313737353636663830326165646333
-30326235656638663737626532633362623935316137346265393639333334623261666661636634
-36326237306562363332626531346434643635373264633865336238363463613735366233333939
-38613037336638663836346237633563613965393933633332656164616231346534306233653862
-63383766376363373362613438383162626665303530396334326161313562323939323938616166
-35613333613535643565623036623262333964343635663363663838386633656161346466333031
-38656263663961386564303337333666333731376665366265643239613863646165343264656233
-35636137613939356263353331633930666237646434353533343965646432656332636465303633
-31383634373732346633646664303863636632346639373066633234313434666139363937616539
-38393338343561643462616233336538326633336633393735356235623464633039366432643134
-35343738343764646236626339363732613638336463646565326163363761336231386234303661
-38303434623637363339363731303438323663643236313131393330306163633733613332333537
-31653064343530363439396638393433346232366365303838303632643732333364393262393939
-38346533613661626337343634653761646161376336316137313163303862663431643963343764
-63323463626338633834336162666236303464323362366138333534653933366436396334653733
-38343439323234313063623438393637656161666663373361393738613434353264393865333066
-38343535643566393865376239303737323430633961663362303431336437383464643136326538
-31363738643134396163633339353735613239316136373133306564303632623832376465393764
-61323432663935326531393266316463373832633039386264356635363261383261303738663161
-62396330636234313162393161353739633235393933643866376466663262303032626464383137
-37663365373635623938663932323134326164653163613263356630383433343836303230316632
-33353032616466383661383636383665373137646234656464666432653432383534613735346265
-38663862626237383765646635386234363135393762383964333961393466636531313335386366
-63316631616533363361353030616562383432626262323961326137613862623162343365353839
-32646631343335656235623931663733323865313735306562316139353363313531653361663838
-64313466323730366661323531643539343464623932373130346538643931333164343939633935
-62396438393337323264633163343436653863663533353630616136316263336430633338376362
-30376232343330333130323661303166653330396236653235356234633338356566316338646362
-38346430313138623761336632363937333732356465346463303063333435396132633464383163
-30323166633138373364343766363765663930316432613037393930326366303563396564383235
-35393633363332383864636434653335313030626133346361336136303631616563663235623462
-31616164363365636533363136393965386165333731626137333635306166376131653462303163
-63313736393539393137663632343631323536373834656165333764343639666430393939363931
-64303332323433323963626430343231396364623739663434306230663032343932663736643633
-64626166383966346266383935646666316363346632313537633137663330633663626537636664
-64656264333739343937393766353564643030376463643864646261363138643061633839396262
-62316565653366346265636536653765353162356233646663393434373030646566323066393536
-63643338623139396136363564323239633132663663386565393564653834353532633165333939
-64383065356161393262636330633035383630396661343930663335353039356539613231666434
-64346536616164623734333437336439326332366661386233336361633263656538653630343935
-64353732373831656532623066333733666537633065643936353330386433616165316136633363
-35323236303762343434363437653535633062383136336461346365343366316234633961393430
-38643233636563636162383435666339393930653163633535626432626631616333373337653766
-32633264653934373730313239373534633035646665336534363731363435353535383838323833
-36363138643733356465636433333433666663653532373162626265353238316537666431313136
-34376466613233356562393935336333663938633662623965636435373561393063613539393534
-32353432353334333534343430356132653665356264366239346165653933343135613566613864
-63653136626462373265373563643634336330643135313831663536356664623631366264376239
-62663135346663623666366235353365326237656166313463616339646363363130333431366164
-64646566663963613263316665343238323432623063386665323634373334373333633564323431
-34373831343934343733373265306235333438393463643039653562373732626234643636323932
-31393966333261343736343539613938323063643138323862666231353631636562656363656638
-61663264376431336238346535663536343363336130396466396632333735626231333037343433
-62373364363338373632633037653162306532613837333830653939386633653630303965643665
-31353666323933383634393363626338643238653439356635613837393530613964363961633730
-39666463633831633237663636616166366362303035346535346666633064313830666365306639
-31653666353762646262326632666536643633636238383965633135623031613963636639346263
-38666662386664653933636535626265346334633136666162663035633138383462393030663136
-63626131613263313733646337383364306533393836303736326135626165333038333438633562
-38326538643131383739376233333933333464333965393761613235306534636133616462343932
-30356131303335653233396636633762666439316230343164386561313365356431356433306337
-61623363616431346230363161633331643236366638383437653766383431303835323434636462
-65653335623036626339303032323535386336316665376465343361326631653031646336613139
-64633262366566363465376330336466643361663532356465366536343463386430623735376163
-64636165373438643539333162376561356166393163313632323661656366323631313731633131
-34376363636463663137636133633565653739386231623039343931636432366631623536396666
-33373365363530336433643362346539333839383739616538363534343866386663623964373266
-62343437373265613466383831386431373234383538633336613664376664613532653035623930
-31353561336534656664376665373834333561643164333030666430366233646432363562323462
-63663733393436636432353565373732613235646566323963313239313961316132613963383663
-31353437613435653339313134356464343965616165373632616262373563313539666533623532
-34653938326465333032646161613964613535373939393766636535393835653461636531323163
-31373561373864373963373861373239313665333130343063323665636331303835373435353838
-36303932666136623439643030323432613964376162646265363535636538663161633061633834
-32353036643065343162666235636262346332316539383736393763633564623935366130343664
-38373735656366656533383833656235383763613763613432376566373236353164373966333866
-65623163373830336434356431616635303235623737653131313766633061326139366662656637
-30326565393561323933623236643438623065643033396465346132613237343030616562363036
-30616366633834323831656362336539636265653636373436326339626337613639616238376233
-30313935366166386333636635383837656133383436616338316633663366353866646164366661
-66383237396637376561653132333066343836663661356266653535313031336138353539613863
-63303163663934353935643266323032336236383239643932343864323832333639376533346662
-32336633366337646237336234316639623230623239313239306131376139333138303065336337
-62383036663862313564396637353836303134353165613233643231633639393537326234356237
-65303130326233626232393733313737313230636264376661326661323233323865333135333463
-36353334633866623338373738313233626136353139623263643138333037646334363232383039
-66346131636635353765313835313431313034373930383735326437613662303631323066616437
-62306235623430313735396466336163303065336666393230393731363433393035383733393539
-35383931306134666366356236323431383265656230323831626565646538383335313536363039
-31643266346438643333313366666165623037336163613332633638323234303266306330323336
-62633364313039626465653232633662656435643533633937326136323465393937346161653863
-31346232383839356639343339623832353936643934353466656166333238633263613335306163
-66623065633461373863383736373135636164623733363166346664616163663338643031306464
-63366536336464343862333036363733323165636438656463343931363233386430386434343464
-39393964633263613566336166306363613538326333303665646633363333613039643263376366
-35323766303235313230323634643063373664373136323964333431356161343838663433366563
-36656136643633626132373330386633626565333431343730393031353730353837643864333735
-61353565383562363862653066343462633663643461623965636235636533336631656666376661
-39316135633238636638353132373038653533396563343965313132643264643730303362663066
-39363265386365326364663632613661383539623664313861356461343263376435353532323635
-65636161346339646336363732663533356530333533343333626135663739383037383565386363
-39393632393161333934663137643366363936643065363234333364623630363835313834383139
-38323461383861623936393466396339356138633563396431643630636136366533613331376466
-62343435323364396436643162303532383466613438326162313561366563386336656330623535
-62613462623935313435626161363832636266623837363163393338323931353764316137613862
-30346661333063656134383361353361363837353061346465396366376466336434333830306230
-35653963393238656431653733653833383963376330336535363335376262396563303337346263
-38343631373735643635323766343461383562633863616434356139633066356332643930656430
-33316537363734656132616666373939393166643263366561373664336230323731326665383662
-61373632336436376638326333323861633832663431373032346565666132353732343138303831
-35313566336335656239303036363766333239306337353166393365323765313234356364386234
-65303136343062616236343634646535383230623136333637303861306663316666656432626562
-37306231353638356331656339653230323836313034653734613233333361646530313638386132
-31656662616633376166653136636536643262633435663331396663343032333766666439333561
-61666631313230353962613262363662366434656139393232626164306163316534366438363033
-66353162643131613635353639346465383966363564663138636535643839363037393139373735
-65353037376632356466636439333262626237663564316664346433376138303235373236353366
-30666232383638643436346332666166366161633266353864383261643436393236616634343039
-37646164646236313063306434363765626133366133393037333234653937343663383433326265
-61306561616631363663623230303930366164333032646235653363663937626262616530303738
-65313732616638636630366561343832353561386334353665313761333365373235323430373363
-36656431653034363130613838646237313432393766323834383938353138626266363565373236
-34663665383865626639333136333565623566653665396266666334346639663261313933346236
-32306436313632313031313035333637373238386537646139353330306139363634306461356465
-38656664666532356435373331636635393664356232616261643736646339633864663339336266
-63333339353833343230323138336136643036363630623234656439616134613263643734396630
-61386231633335383438643931646432343362633461656465393534613965623365373635353532
-62636365386265613931613635333364323334316663393538336238396265623763383965633264
-35613165333663336237323932356263353338613962613664386565333133393331376534656662
-35393638636662323834353962383663316432313163383730646433343233643166626138656265
-64333230656163326661663362366661636536323238363163323062633837363839633334356362
-65653961656465346464666662353165393462363234633366313836616230386433613530326137
-37376230383036633232343432633532323965373639343837353162396262363536316530356637
-36623338623733653237636135343731313939626637653364396632626532666466313733323564
-38626565613936373332633234326631326332663734393633656235623536333066336436396231
-32346231303031326138383734393833653539626436323061333633616363313562613666656462
-61646438366638373364616232333930313030663536303162633930633833376665333132323062
-36363235363136386238333563653961393838633165616330333734386439366634336133626633
-64393636343766366366376337383864366430656163623464323966306130323265373663303733
-38393736653636616563346463303333333238303835363666383835666332663066303335356266
-35376661613561363033356265313439623134623435666132396662376633356138366161663139
-35333262653334633838386364616533343562353431393335383132346166316336356366663866
-36346364333932636136613138386236356437626264636561666362303739363039643039323132
-30663666363432383230373361643763666539313237326461336135303634316439623531303166
-66316236353031346661623961316139336661303362356535653035326639316430633662343731
-31656531333735616130663432663232366530303634356635656633373533666533323765393732
-34623539363462376631373661386666626532363962306461366265633535383166393330306263
-37346435613364346339313738306231656635663963376132303236643964383431386335336233
-39366333646234353533383932623337326466643734383437636662626338623131333166316436
-66663135663034373466653761396237623131663665373963386130313130643232346463356563
-65393038373433303631396536343334303838333662383234363865336134633132623633386162
-35656163323039646234633134306435376133386264313434333535613638323333356536386635
-33616262366432383034303534386234333362643533623932336435636234653835373563376166
-31386433643062323563653335616630363436386661643762346238303131396138313963613437
-32633330313831613133646434623364353032396338353861316337656532613536316334663139
-34363934343161376531363632616466663032363638363165373030393266323561343664366233
-33336133373032653639313031376238326465313334363763376238313961653863316338343732
-63333061663332383666353361633066633464663563613430653539393162653130636561653034
-39333665343065393739383864313835656132303239306263383065353834363730346231316338
-66396635383163333837313339656235316461366335633637356363383634346162333766646139
-39343336323266666131316137333663646330653133636266333961313962313837313338653738
-35356261376661616237343630303561613334343264383138313535393433643234353761396137
-62306266313134666330363564313962623635613535626130623861613163333362306134383831
-65323562656231383164366265316164636233646339663131303635343031643265623137393338
-32393263323238373465346266323962646565636237393237623938366537313233326132643433
-39663539373465623038363632633065366539383630613735343663386432663233303939336234
-37643761663030616132343339323864313839393534613637636633333032623738623934303330
-32333263383065396435343139306535303663313463626166643630313266626330623161623131
-64396536383337626633316664306636393065333732663231333336356166333066326134373865
-61653136626463623464663261366531366266393033336639323031376264636633653564366463
-66313266613661323331656661373534646638623436626465366238356637323661616365623134
-33306334336262626333643639373632333439373034663639633830386662313739396239643763
-37303832323134646566663135373636343861303162653064353465326261646336303465383766
-31333361636664373533613336646564333231313137646466666333346632613231623162363336
-63656133306239303134663666323434353638323130316235303039643739656433353636363961
-31383463383162326562663532616633386163383665323839616165373335393733613331336464
-31396662653830623334393662376436393239303931313130623865346531313363633566323231
-31646462636535383663613330356436313332623130613339373333336637333833376161356665
-37396237396634393739613635363038623738626461636438313732666437636164643831613936
-34616536346531373865376438386133643462383961636566316564366361393736373763393435
-61373238306464396561376362656532616537323364313232303164633762633839316233633662
-34613139616331306631353631613330336661626263343936333237333931356364323439613937
-64613632633364616465623566643036666164613861373462653366643031336339343432393333
-30356438643261656665666338383930663835313236623139373832643564663663303131313362
-30356365353364616338623235643364333537383236613865356138646362653039616666656438
-63366333313163626661626636343932333134363434353466353762626430336264353439303763
-61336437346663626534313131336430313733363365306533333538613937353265353364386137
-65376337303762346534663062303263343237643864373235633962663632353764326363396436
-34396638646231383566396666323564626231306130643436636562323566333831306335363665
-66383939373234346530326337656430333236306466336239346631623461393835353361626539
-62616561303932336362643164666236353162363461636131646537636364666162616639343961
-31353336623064633637633632303732613139366662386339633037613964623535383331336361
-39366533373039313663393639313730666364346462323730663835333132353864393537393263
-65613730336233373233353066613133623762363437633564636364326233366265643736326264
-38373736386265623863343638363465313161613836656531343362383533363262643436636135
-37316435643461333563356430313538326665346363643933663333306461343734666432313434
-65376138616263616464306165303062336535333935363361376635623538636134336238646539
-30663532663965333534396332623366353132636136636465636362356662616335636364383037
-33666336323033616634613766356437623862646631303237383364383863663363343731633231
-62636139633237386239313061326436653030346336336635613164326430313337336636353732
-39356131646338373933303964663139373463333663643031613636306364363033623034393331
-37336665643163333432386130623430643631303363356235346462313631636165306465353634
-37333333643766656664613562306331303230363461303138326337373537333832663735636363
-63336664616264323838626465623765653135623935333036613962316139323235313265633932
-30353965396364623138316639323065623635353265336232393032313366376335663066313330
-32333465623238633031346139363432353833366666653665663936396236336662646239616266
-34323235376265656361306166633833663464323839613564653734623936333963616436666338
-36656132363335636632656564653434383366316334326338636133356533366366356166313165
-34316437346231336465623465303130346162663530643530633230393165313939303435636538
-66626238353039656136626536303135613366323064633434383462346166396361366137326536
-37623665313662333838616430623032306662383764396661376664653237346263326265653535
-63353133616130646436346366366463356536313830346262646239613131373636346661616565
-36306235323336326439363732363531623564323231326266323233306634303833333465643537
-39346136613063373263313734643439373963366331303236306661643230383261303862666236
-37636639363437316134326562363039653464616161323439383831383136336662303861613165
-36363961323132376566363439353063346230373333346363393837646633313433333939626136
-35623563666233306630303931376436343436396635393436346333613235356165616335346438
-37663138363335383232333465373131306632396535326331323665616536346430373435313063
-64333735383066336636613034346465656165393936393061313137623766636431363637613439
-31653562316334333261343636666464326235393030613166626164623232633239373432616330
-33663036346330353161393035343366663561313663346534306231616165313530656139393836
-32346331393265663363656439343564323563343165393866366330613263343939306531646436
-62313535306639363734386535663630383833396363666365393334313161613630643032613666
-65363034353131353134363230653264386631346334376664613161626562666665303834636165
-66376564353433313734313462623763366564396537333033333838323466613666613665613130
-36383664383630623866353164316266626333613861383737383065393536656333633038623437
-61306136646233616364346530393762313037393434653234633939636163303333386162333934
-32643832623361353564653832626632666432633734386433366462313835363834343030326162
-30393062306664616633366662633365303030323462333039656130363339346465626636323765
-61623962393839306338613131613362326661323365393732373364646365633466623934623765
-31663139326161376638353162366662313030613336393631303634323532383631656535376335
-33383265346334623166636663346635393430623034373663326364613661363035623065376435
-36626636333163356138363065363236636535356238613565636538656161316134373138383138
-34396135313864383239653864316137316533386163346235326166353335646363313765306134
-30663734303938633232343137663632666666646138323566313935633739373863643266303164
-62666233353764393462626239616135663732633232663031333333616666396566333063656364
-37646663386661336230326532306134373333363135363265396166366330633866326664633965
-37326637353530626535613561373335333833383261306633326330313632626264613634393962
-33383730613366306433643036663832643230356131336135323033373430663963626138303038
-37626338343631386565383832376565306164333661643962323037383666633965623830323862
-31633132393838616165613163343261353438376663343465306438366435663961303063396136
-39386330663932643536623063633437363064383031303832363238353162366637633734346266
-33633165356533613362353333653535633433303637373331643832623563613861323134323361
-33323035643839333733613261383463616537393639306566326662626430613933363764303134
-63343135303865376161393465343331623163313364616637663439616366373666333537316462
-36663039373032383138626266646232386239653330616665623164373166636334363064303066
-30653131646631663237656338626464646262663464316130613236633836613230376538343666
-66616631653063636666613238396533386533653064366630306632353533303037343561356636
-66313164623334643332623939323230323637616135346566303134643137313932626634386561
-34643835663961393265336333333435653164316238353965653161333538646635366161363261
-34636434613237623863363663333662323638326535616262373263613636663139373536303432
-62343035336165373263363430393962343961376535666664393162346266646132623134623331
-62376633663531313363623930623965313832636564616663346662333463326132326336633863
-63643536633461383432323732396463643565333733643166376165663961636638306361653037
-35613066306331633334366363333765386431353861356432393530313439316131663562383832
-34383734666239393562653661623436663439633830356166353537353538356534633462613661
-62313636633862656231373639393962623038376263633566663930656633353264623564326339
-32373662643363333934343537633237613063623363343935333430316132373965386237313131
-30383337333134393566313539303536363065383862323236616363663839373136613438663230
-39636564633536326265643537643863666631623439643763376132666534646334613563386333
-66653735643736373566333030653938383833336430393765333230356539623237646166373532
-62633166663961626332616532356562313765623162323836393932646663653034623433396162
-63636161616563663837373934303338323263306461636664643665336630636564363666376136
-61396233336465323338623138663231396134656435323032623363623638633330623630303630
-65656665316261363333643735333432376436383236333431306465386431346537363733666563
-34333664626130626666363039633337613665646265303737336635623963346330363738303238
-38396537633965386263363033376632666139343737343335626637643230386136353735366130
-36313138396562353932623062326634393137363964336236666161643334323363323664326234
-39613234323834366431623862326639346630316234303133633133633064633665306361613366
-65613165666633656534326465313264336438336564616235623732653533386537653231343432
-61623665613461643436323139663763656230616530623461373135636666333635366461303964
-33343662323635653264316333316336323533303765356533643135393564386363663532653463
-32356161363332373562303930623730383965656239363035323461643363393837343334333238
-33313137346635376635313638643136353131653733386139666631613739363133656432316239
-66663531373534623165396363363665383664623362613364653937626666666365323462653564
-33633134346633356162613738313439303231623262376338646266393935303263646131326363
-65313039366538343532356132616362653463396439373161376535383435353430353863316130
-32373838343661386130326136636362323963303964613530353763326130316635623564363837
-30626333343836626266616231643438333838396134383833303131656136353539663265353263
-32373064366365306636323633323963326530346662633436643864353163376232636562356437
-37303462663336336433626437313961323164336161643136636635373264653564383734363339
-62646161653163336530356630663537343839373637326262343262653832356136626239383231
-38663538643437613463303032396333383232653934373866613439313164333933613031633566
-34376666326334353831333931363665316564643461336436393763643539383831643132363363
-63346638373065346565386266313563333563363965633831363930643432623939646538373835
-32653035643339336239306564643866313934643937663461373933633930663533646264323338
-66633336616462336531343032386538393262316261656233636234386364643333326233626538
-35323538303863643137386662373234393665623261373638393361383034316161316239643839
-64373932646631643166393530656237656437386138386366643266663733346439363461393763
-35383465356239633939613639653838313061393064386332623135333761313266343737366537
-38623661363463343933643135306662656435383961663463373437666265366332646462383730
-61323063626161613762326462633336356638343638343638376434333833333038333461393031
-38326330616261643833633730393663643465343064613732386539653635363330373065313638
-30326537353737663031636532326432653037383138646337393230353865663130626532353731
-34363365656131356362323531663861376664386164323663663935323739613261646630313162
-61373835313164363235353861633739356631633330653364376232643636303861313965653266
-61343330313630356266303238323764383530613563316261646139646333353962376461346230
-65343939343533343565346138393465313361353130366638326266633135363261643639386535
-32333639306634373136343261383662333034356666383333653732663766346631323963346233
-30613338393039393238636462336166393335353263633566353665373733626564656133356534
-39363332636666323165373562353233343530383864666639356630373834636531386363653831
-30393733346563313563666632396661646139653261633836636137323934336539366231333961
-63316366623739613232363738343262623837323238623131356431653434623630643161363533
-39346661336263306635303830303562323864636362646462396538356666636331633966326137
-64303162303766653534363133333135636132626237626633663030323836353430613532303764
-36346365313339353235633338303165363164626433636638636431613730323533386261666165
-61316263386331613237366433383130656134393762383033393233666335363066356536336538
-31643266653135373735623466353164356665336663373530306463363462386634663562613834
-31636237623738653163383839633064343236306237373035343161313433653939363539353566
-39626130646138616235383639363139643236336163633161636461626537623133326132303830
-65653537653362643432646332353335303734346664313139663237633537626464383136306230
-62303762613836353663346166666366633538646232613165336437343730393137326439356239
-35653038623231313366393035333066353836316638643666353162643737396464653739356530
-61363262653938623866653536663930663762363162346366613866303234633966393363336564
-32633839396431363266383836383266303038306465303865383164656661636236363236313633
-66333437613366363663646363653937303430326434663161643937646261303464643132626264
-31376333306136663962633664363338323330313539663335653134646262333239333636373666
-37343335626662356631313961643636366537316234633136303965373930326133333036656635
-61316563363666303432303831316532393833316662393864663235623161353330623665363236
-31623330646235316134653630643661303534663739626435393637636233613563626632663866
-66653966663462653233393732313638613163313132353936376162613039623365356264623431
-32326131376234666364643761613464353266376337636131653932646538663562643335386565
-31353338626166333137386633643666363265623437616162646336636135653336343935663235
-30626463333136333833313232616438373438393531373130666131366463623239616263623161
-66383565316166633064663032643738366635663764323534366432383237643130656137333232
-64373964376261366630646433646361346363313532643336323233656133636433663661643132
-61313731346138393236326565366662613936323561663864346639633733353364363738393065
-32306435323034363238373362663735663734323731616261396365383833643130396161346138
-30623034306561323633656232336237303336356131373163333134656436653435623161363363
-38373634353263383633363564383762316436613461653839663234653163333233353465363432
-62326232306134633131316665383061663632633039326664636166383730303762666662613966
-37366238316137616232373531396361646537353564633165666165663165363530616233363633
-62396165366661336638313863633536333962373334643737613163303163626637613230623038
-38363465316636366364356535653331666366353032653563633364616562336161366234613536
-61373563646336383765376335303739636263306563613139636464353061633566373333363530
-30323832376131623232663733353535363731306435343166633537386631353533383835616235
-66393931653836393935636534386266626337666434616433643764323364636364376562333536
-66653138396530656433346664376638306337333637626230363262303963313237636636646265
-33366636333263313766613934666238313034316238366163626638373831353561663334373136
-61626663336330363333643563636539383865643361616333373238396336633636613165633133
-61616261316639313831656561343530376439623262346231323466623538643762373037323639
-36303463373537366534653838616462303831363630303430313136616664373261386132663961
-37633036316463303836376363373463393837343265653564643031326536366539303366623230
-64663764623961356161653661336161616666666139386465613939653536396361626363616462
-31313065303461626635623034353634383862613232313438366338613735653363343963646262
-30663431323065333836653137353562653539393364623131636363613833376332656236653362
-38626339636336616230376132623531633532386661303738383234393330643364623866613330
-34373565356665623234623865363933376332373434303231353938363734313036353938356238
-38636265616665313165643434393862343263636366303734336362326633376238646136333535
-33313933616239343132626361306533353062353836666337633339636265316336666532646331
-34346335656562643439306431316430616163363939373161306239643563303665313936666665
-62626164356433653130383461653134333935386232386230373432393133623736333262376438
-62346134373533633562373063613062636663663061363361306361623164666465626462666363
-37316163656436356635663933376561396132613961366237333130653438343333396135313235
-35303234313761653932303362373034323630316264633061313766383463376439363636363338
-31353664373866383531393930616163653032623439363636666461323962343135306138343535
-33323736343863386565383861323631353730343562643638663365393863343666366434386237
-65636130333562353535623330333365343761633462626630656662343866643963303364643365
-37393835656166356234653433303532303832636637633139363131353435353838616230653663
-38396166393163623264623063373735363733643436383133303366366131613865373364663166
-34623339313932616236643632386138383337373433653462316432623863313136663737656531
-31616333373261346461336233366436613162666331613832346134646563623166363639633339
-30643664636433663732643432303136346536343765336532366531663237633538363334363133
-64666666303265396235336564623362303866373434306635656465633363363765306631643064
-32393466613836333735343465643361386139396338323164333739356339383065313433306137
-66383431636565353664623464353836626438356663383335343362396536616136636239613764
-32346432636238613537363631333634616366353238663132373564643232353261343336363363
-64393730303131613533653435393833663638386239393732636535386662373361303932313065
-66383636343930623230336264633431333532636166633437393263333464643932386632663734
-30623130396131633531323937363035396330613536666132313339303436323039663766633637
-61336238666362386139356431376135396232653035623035643731633836336331646263333534
-63306231323039643336303763383139323535366337363733613161623564663363656661613261
-38303137383533613436393261386661306431646237323439643136373439326531313831356639
-65343236303963663131343834376362653935613963653135633266393361323663386437313836
-32313735323730373432323535613365363630656538313435306239313265633831653065303832
-61336631386230666530323063343936653262343664613438656162353437653165653835633936
-33326331346431303266656238643334643066613130656662663830366363353036326531363430
-62646433333837616134663931666231613434613932376664646234646436613332316334663961
-64633533623938643039393066356461373137656235626238636231666436383632666463643038
-33326662336130643532303731356138393335353830306333346166623264623831666339623639
-31383134613564373130643933616138323138363839343034313931633562313263396364373130
-34386533383136343066313834313834626539393963633530356461313830303933336264303663
-61656138346330386663323335663735373264616434393966373534626238383162386638323935
-64316535643837336466353039323764636332656537373136623830353161333730353139326136
-35366435343036646134366332333038316563656331343138383431396564666539363262343138
-61376264353638663532643066636663663839373630333033303738633037623732386534386235
-61616339653537326332616631636166303366623831653536313836373661373565326337376266
-63343630643639323736613164613561393434343539353436633236383062376663353263383561
-33373139356264393838376632633966353930396631646166633831653264633864323562643630
-61616233396163623364393335666338663336303431363364663732313931333632303933356330
-62303636313931623061306437356438646136383362623765363566383565336330616463373233
-31393162663461613336623063393238653739386365343162353430666537383738346236353533
-63396466313162653566653262616138303635636465393361313861376431633835333265613663
-64613264343533376239623139626563343236333462653464333334383839633430363138623565
-333332666237633537373731303433383762
+64663832633332306236376137386432656633343763306539663338333761343363336532333431
+3038306538653835366230353634326339636163643635620a313965363432653833623361633836
+37336166623333346234396339353166373639663762656431343530653663623334366165616639
+6265336231353738620a366235643164633937356363323032313239353535393762653766613435
+37376263353938623534653038666531656163303965336165356632383730613238656364306533
+62636462323037393364306434636434666331373265363232666639373137363038626136666462
+37663538343237633833623836303465353134613632633731626664313739616337353639313464
+61333564303638613439326130626464613262306130303461626431663234383032646636663035
+62383739336365323639663433663137373031346366626230643538323461323232326439313938
+35666530333730653563343964373737653036356532616436626661376436366665336434646135
+66623030326230613362623532313066396633633666346230353232303939393162333435366634
+33653665653834663833343964333366393932333366663264323535343335636538313962336336
+31303464393963636635616138363438333430616462333963663662373564393132323663623331
+34623633333634653934653166633534626463653966646339643661383764626239376364383938
+61623564363032666565373530653133653265353563633339333966393234386436363932346431
+62623832633761353536646335666136346465653232386437653435616536366532663932306362
+37363836333437303737333136646566376437343138333865306230636132306463393566633965
+66366632643036386235393932353561383730373432643637623533343239303036336363386263
+65646639383631623733383637613635356161643065323532323766303761656536626562623835
+35323731383662396233363631663433333632333039386662613032346235666637656464653434
+66626230653564303939326138336639613966396536653737636563356432663434636332386437
+35326632666136393932653039366137303139623032363635393862303664333938336436306432
+64353063316234386665316436313330623138396439363035623562343134653735633030393063
+36383432313037373933633036616338333964336537366439376630346166373234346331306135
+34656430366238643263346332363435343435626564653638386461383162643561323065346365
+62616266343132623033303562326434316661353164386663636265666561643137316665656164
+35643332363861633033373764383065623463306630656562666566333534666132353939626532
+65343362313135383035363431356361383866623334346363313262646363633263326663636536
+64613134646337633331343561323839623933636632313863616130396233666330633764343564
+62356435646536316533383736383838656630333436346466633265366365346238633931303036
+33396631656433613364666331663630366365353964663232333432643662343434313164366435
+38653162376331356237623230333939376532653337356166393633333438323639383133653863
+64336339653437333263373764633934313632623838656262366430323038376432373138313530
+39656563376132333730393363363363366461303864653231636635626462326639303039316663
+30376662323961346533323636393832316261623235616536613437306631303163633966636233
+62363063653331336362393732333865333132393430383932613466353063316530363338303065
+39333461383434323435653063343237613838373661393535616538323430613533613034353238
+37333166626664366239623438613434663162353934306563383334373064343937623863663861
+64333635396636313263393136313266366330346535313164346363323462303963383861323439
+32346164333464626462376231613931353131643538373264376536386463333163633363306662
+65383563323166646530653434323861616631396266636434303831343766303837386465666432
+61303864646231343438383466386162626639626566313433316339643334336465386263373463
+32343063643630636461336664343432373561313231316563653735356633616666633065666234
+61396635643864356263666432326231386337326532303936613764623133346666373033323062
+31633866616564663839663363626232633331343734643664393131323165386564383535333231
+64306638363764643430626539643466643336313063613133633538643165336165356338353561
+36343961613536326130643231336238656130386163393765633235616563383566376438336161
+34653835306566646564643161306630636665353630323534313264313063363035623437316336
+63363937643238313839343837316663346439313632653965663831343939383131373364653235
+65333833303231343639643937656335336433386161646462636330346533643864613534613430
+64383037383766623338356137663139346366363963653732653335393933333937386263376432
+38663831373061333032636634333339323366343733303534323630393561326630393862656263
+31663937646333306464333366633164616536613136343166663530363064636534396465313465
+36623764626263343835626361383031616336623133393661343565646435303365383333386634
+63303338623231313837383535353039383736623030656165326534303934636665393161366235
+33356534303162333437633335646433653032326537623066383531643938396239623937613837
+34623863393430303264636435656137323462356134323437383462376435306363646335306162
+66303139383563663762666561363939323961643730353864313666343131323537613631383235
+33653539316532353234336331336136306662393964663138386536346263356161303734663266
+62643262623033343637346432636231623136343431313766363635653939393031353434663134
+39393863656436373239646231333766303034643131343462633665396665343132333435633062
+38313530653631613961393538626138336637336232336262303361393431323336396331323932
+65353562653332663432366662343461623537306637313065343739626532626263643437376334
+63363435346131333837393632653436613537353135393633323332653434643264626132623931
+62623765396463633034363065323262343638626666663831376138383035333236646564613531
+65313436333333616230313866366466376363393461316138653835633763613864366432643962
+32326636626361326538346262356563653864343765383633353138366261623466396631656434
+64616533343933326664393835393638303130666530663538653336623337663935626534356433
+35636433313235613962346238623631663930393064333236653030656565313537633839356631
+62306530356237393462653964386130343733313833643530613037613331646138313663303165
+66613136626639643836393534313530373732356130653339333238633936316663663665633530
+63663534636664326463353930626164656539663034353261366330643438313038646132333935
+31653266346465656665623862383332336362376165656466336230623365613363316365343861
+65666263633430346463356366653933653862623764356233393865633163656466333234363763
+33383430636263613865643837346636623731363537393961636666326139383762343932346361
+32383235373030636230333438306662373565313665636363363666326439323962636262396335
+34623031383366363961383632383465656262383035313635643031323334326337313931656231
+65306165313663633537663134366233303736656339653837343362303064366237303337346134
+63613832313666313633623835633931356661626437343730633532316262323139376330616563
+37366661613633333337343431306631306330356262623564666164646666353563623963323232
+31383034613064393663663935616439616466643037393130303563323261323462303433633134
+31653262323163316331333735363730613135383266353632323636393963393264643234666564
+37383739383166323136623463633962333937313034316334386462323561326433303138396635
+35643166666136333066326131353134303030656265343137353832666433626331303866663062
+31613436333138623534646264383537653866633032646164653936386135663065346438636365
+66343033656135663833353039373161633735313864326438383538616664653637303738633835
+64616361373138323865633466303834323334396166623139333762396330316637626162396437
+63633537383563303065376337346131313336366364646666643733663962623361616665363036
+33343238316361353930373866396134386363626664313134306461633865353336646137353330
+35626534336337656637653435306537303839393662363936363736636433363937303066646461
+34323530353338373265616534383737653465356166333666366537663966656264616562383639
+30376564363462323336376537623638346364393363616431313462333335623563383338333661
+37333865383961643362333138383935613730623661373439616664653837656261346662323766
+30643330353639353436363166633362343061653738303838333163663037353734653562333863
+32613765626465343861323565316465393335623062396539613762333535353538373538356137
+38306138306639663065396464643164313038383132663361623330616536623461363030643434
+38306462626438653832623735626138353138623333323330616139633865613436326366636332
+39323038373561353434633933626363393034366633653032363334363931303033333064343662
+64326130343632393433376166383432343362616366306534633162383634663665613438356430
+63356661623035656137346435356665623138303537346634333239633532373466633034303332
+64376335633434346566303833303961643761616365656138326631353462653339653431316364
+37363563666263386233653138303533643234396265636133306531303331373138303436316137
+38336662313963313537363430353936623039373364393930613134623037653661636436316539
+38373761646266346232613935373936393661613463373731313961623935313764656333386563
+39643033663532366665333264356466376236633464353365356334626566653132346662326535
+62633432336462343739633430356533306638396362363434623231366130346665623234666232
+63363839623432656136633466663462613633356331353234636337386535663635333661663464
+63346564353561643137666565393031363662643465646434306332326335373638356461373835
+35323664376135323039616161313930333237316631326563316264323231396462383334653362
+37616366653864313663373430343337643431373061633161353338353637393933353464633833
+38373034353234613763326165633163316236393739356337383966643535333630333231323838
+37313635303930356138306637366234616661316431333936656563663430316332373635653637
+61323134656137623835303964613231373234396663633330626133656565316234353964353535
+63363839343565666438653331383263346630623566366136373638346232663865653532376132
+36326435653236376265633230323365323466343966303663383538333339333034363635343137
+34646437653232303136653962373331663433653662386539626337623839613063383237333836
+33303461346132373132616530363932346265306238393238323033646466313935323663626430
+33303833393830343630613732356262383237336430306532613437326530623364326563626439
+34353537626631626464663839616436613234633231343166326464646337643732306236383865
+36303364363639656662613462323532326533343130316466663066626631376337306334613864
+34333266393336333964656462396462633735643131396134373363333837393265353434396437
+35363738363539613431666533626165353263353562343862616432336335396533343238346233
+33643061376663356332313332396332393031383163313464353765613230313833633737623633
+32623636316261376663613535346331653735623061396437306366616137396132623131343865
+65313037356562666463616538353536316461636364616463396236343366383837376366316265
+37363163633362633032613236656661376132393732356332336333636364653136363535653665
+39346632663065306234303361653862336534303237346638383735326265666362356638383634
+35303434636164376430643034323236373066353730663163383361323562323165623634326431
+38646636323365316666663139393764613762653264663465303535626239656433343736386139
+34643263323230633564373132316364306437396466313432343835613335633666323366333037
+31376532333063353634316331636339383737653765646365656562373937646436323935623361
+37663264306236303831376532363131316537333431333463653833386134666231316637613036
+38373538623737336435323566313765343361313531636365333433643335633234613335353336
+61653837356261653165376237666439613333376232653833346665643962313732313162383734
+36346638383266303832336235656664633338363166323331663737656631333539373731306266
+65653435663731303133643466646263363839643334336535646633333937626134346162383465
+31623163306363306530316337363733643435346337386138633334653638613131626435346334
+33356464376338373665343236663662366334646237313566346662633861306461306237353432
+38643366636237373532306230623836363935323562373964393330333635303634663530373530
+36376334633839316566646334313866346532373638613435303935343932386639663137343262
+37313366376166373434363134366264633162383366323030616633373063656230373061383139
+31353930396536663838643661383739376136396534373465616231323334323435336566373862
+34333966396134386538376632323239666638363435343462643265613239643839333739363734
+37393166663562623966316137343038353233633864616637633166393732666432646330353136
+34623632383230636165353966393335346331633663663066373034376264613966316463626362
+37353061303964343661346463303466386439313239386164323133613366666538323134383639
+64336234356465373437313738373663333961303466653862346137383134323038646236653334
+38613133356361343962643134346234336333663437643136383033663330336332626334353135
+31363730656662613730626530353034613039616639316263306238613463376132363466363732
+61656630323933353134366236373136303633376538316366613637653630626464646639323839
+33623761373936303538623634353835626662663133626135653036323235613037333266383164
+37303835633038336635326531306434303333353437363934356132386333303566373633663365
+37313863303134333430643034313765363534313661356161363137363430613137313239363963
+62386264626437656336613566363561333131346361343666373930646364613931386266333435
+35653361386161666132393461613431396536653837666639333763663637633633643238373766
+66326636653138326331633237363337626135383138663636363430663364356364653937353665
+62373762356530363731393233616638616463393038313736646236346439373162376465613062
+38353639623638646234663638323130626162626631613331333863633132623066373634326664
+34663332333565613962396464306632393339363336616564616463313366396230643666333366
+37343930306461336661613939326666323564636661313661616635303463316135356566633761
+33336432356166613230326266633637333435363237376534613838346136336534343163353064
+61303933363630366163396230613466396464306337346435386432343066616533663063666137
+39306333623335363263393465663132383663656664353161306365633433326538383731386265
+38346666353034363730656662623166643935643339343765633237356636326466623163306639
+31323762303932323733363732656635376639373233303338303763323534376264636466666162
+61323336623539386464363863383436343439346464626531346666326134653634303834633063
+66333539313838636334383561636662386436383434393332366562323735653933653838623734
+39623862633762663165363661636130643135343334383635656163323864366261373437356339
+63623939343435323431313364643538653934636132623165643266613662343661313162316535
+37306131343765366634383463303339326331656363363535663831353763356536326233373332
+32663662376532643733633230633866616563353034303162383338346638666139633663373839
+36396166363338343839343336303461363764336132393065336637633939366131343566353661
+30396135383836643562326531393931646638333462646134326563656130653030653237323963
+61643164316337366337363930336431623962313862373233613635376564633839393034303633
+62363432333239383031313863633235343131333831653262663737386137633162623832343335
+34356165616633366566653838396536356138346464323037316633623762346139663165393965
+38313634353538376631636433336236376164396634386561646333333361633231343163666465
+39396361663332663563646135623334393731626262326331393439646665336539643830323139
+66613538343030353533643337653463356134383762326364386231303237336138333838623563
+35396338663630373765353834626232636533313038613838333135386136326238323766623331
+64653265323265626166373536393731623038656438643635323231373836643830366638633232
+33313936376233653436323865393266313534376161373862373038396431306339663433646362
+61363132623735383162326266396465653236346235346235646437633130323630663266306337
+37333633633331396166653837323361653236303262663539613637316261323966616630306663
+38643134333465376462663732643266353231353666376662353763363930633632643166353637
+34383963613035653537613535366565626564333563663764303239343665663062373533306234
+66353364633838363162643732396236303166353535383965393863353034353033323661353132
+66353030653833363434303162333766626531393065313864323163663334346137373065383631
+36306134656233646538616134646332346461653331653566613333333061303161663338666163
+36366233306634363038356363393536343761626436373263383836663665393339326462613834
+37353136343534643165613938356234363964353163326439323534306632663231323661376465
+62316131663136303564643034313938366566643739346461383335373362636239623665323233
+61393664363336393961316461353133363830303939393633656232363037343564656431333831
+65353236613566663462613130313032653936373035366462623539326237373035633436386439
+37393764396639386539333562363532333961356332363237666662636137623033343766613135
+39653531663661643135323333326532636666383830366461323761653933643661666662306665
+30613236333738643530656163626638396265336131333735616666316439343830343539616534
+35383163656336396364646536356339323866363065333039666435346534353932323865666266
+64666539313032613963346532366165363465386538643635346661633732323135343964643938
+32353965663538376162383933366539646436666139646137373236643234343966653933333265
+62313664343338306532343164393162666665313035303231633239323366383663313530386361
+61366363616161616265646266343638333134383332613364613463346534646663323031643465
+37626636383335386361343837303866626136643032306664326362353666316638636136646465
+66376536363565356137353432316238633231663162636161363633616230326262376466656233
+65666266383263666239656635343162363439613830323762616534393238323338663234313933
+32643132346462353564373331663730626535303966626530316136333735353065343563326636
+31393638343732343833303066663231323063316635313237663961653732613963663237646362
+37623832616361666136616366636661623462376434616230373839313161656531646564373837
+38383566366264613561356137613130333131643263613831313866626439373439626366343833
+63343133646532636232353835363435666162613364623532363630316435633838313535303661
+34656331316132626361373238323262316531313036393430363138626265333263313131393065
+33656239643035633737306164343362373662316164616165353933373534393664653736366265
+38393832396131623132336535376361636162636133646437396562393533346261363236653632
+33323036373431396534306563646138376433396261343138643732666162646137366434333732
+33333036376230326632333230366233616263366237316365383832626136313761323563643237
+32616535323536343134653434336161616139643435663536363466333165353438363063366237
+36636663646664386635653034363663383839663361333965646262653534636266383463626433
+65333935653163353564353931353261623861633938386330353431353331303735353566643434
+64313932363030323565376133613061646630303165346263353266316266663832633134373161
+30356566383131396635373131613430626233666633306562316661623330393464643961303430
+35346465333361396532316431333165386334363665656230373936626435323364656163313239
+35336365393437616634353032313161633264303131313338376365626165613465666330303336
+37366365323463623034316262363538383266613137393333353361306436316536356638376165
+31376361323165383932396438343033363063633535306133376536316531666166636538396565
+32323461353562623630613932643239346332323661663963633961336337313035353439316531
+35653833366232393561353965323964363365623037303964646463363937636134643230383530
+32323836666439366536303332353134373264393634303730313861376164653365356633336238
+35323633326130616665623464646661343262643838306332303830363734356132323633363539
+37343537326233383533626263623361336566376462386661663131633664663534323332613830
+65616237313938376432626464306434633032353564663135343034336634343166383762616434
+62313336386132356563613734646337373632366361356430616138666139333061303962333330
+61336366383765363233333833383837646633363439643531366633663561663035643635313732
+31396334646333333036316162663861363565326532306336313937633838633534663263613836
+36666266623130366634613235323330356338373564623939626363376366333334653231633866
+65643839363536643363626563613262623665336231346266613061313339333962656264636337
+35383330376536376166386164616463366266623662323261306361366361313534383031326362
+62306339323636343332353935643064616561343931303564626265306361633336656466663161
+39363434616361643432306566393939376639316438663764383930656431653834613763343332
+66363963666139623333373335346461626534623934313862323163306238383032333530333037
+65383962653331306234366138363966326164303531633830616165343730386233353466306133
+62373866376436323638376131386366303465366337663665666338353433626132343563366138
+35623030356239353734626161326435316461306262396662653465396361373663393134383531
+63313933383539313338663562623265663566303332393530656434356332623438306135303031
+31343434396630366165646638323663343839633933646431386431313732303239373361353339
+32633063333366643632326435363436613437656137366435396261343033303735393730326136
+33326235396332633731633764346631323763653266613332616336353363323065316633393535
+30326363356663616464363231623365336565356136326436656639613762343139343864616137
+61383738643934366465656666323164613936626233333265333239623234633961346133343037
+31316332356336336433653830353139636134306334396362346562353432376634326132653034
+65346635643761373061373636623130646133303866346566326366393133333761363065643266
+30356366613232343037373631373065303937356632643565383130643533323732386566353435
+32336535323538373537323339616565623433626432366637336239636131626332623166666233
+64386132646337656464316633336535386538316532396232623866343961666532653433623534
+61363538646138303563346465633337653330613161643331663564373961666234343631383739
+61663661333363623736336336353931373733363662616565323139353036666564313530633333
+30383365663936306566386661363965626239373131616431306561303336356435363439373139
+34663933393137616333636239303664323034626237646232363762633037643530623935663132
+65343231346230393539386262373839393033306662666439363435313562366663333762363763
+39356433333464333161386165303233393630626666313639653263343032316131396563613864
+66326663356262393435303134666135386334613031396435666664366133343661633835333066
+38373163333830303230363963646337346538316132663466333039336531623163613731633931
+61323835636263653038393435626533323033613462653031363033656334323031623037303466
+39306232643338353137383430383439333531666438623732313438646536383137383530653135
+35393264653337663737623931613338326464363061353631326137303761393732323832613864
+33366665666639353531653631613534343636666263663261613161346336653565376263616130
+65383630663038356330313538636364393565336461643230346631666366366536646431356332
+32633739366362646339633561336335646536643565396561353039646331366532363563663236
+62303230333265363065396434663535313866633561373436656566313933313731386231303962
+35376264336465363364343633383765306437343764306234613637396239376637653236306436
+64613038376536656437356236363165633430316632333462663631636530313161653534363132
+62376461616137393838373965356638633034333366393436336135333837346261336331346230
+65356665353061643464643332303032353133623139666432336637313832653063616336326166
+30306236383839313338356161373862306135633233363336353264396464353338383561376330
+35613635333536346462363661306265356437613638303938313662663539383439623035343035
+64393633623430626336383738643836306362383735663265646366306135306339653361333661
+32666530666234393939343230396565306239386333653033393537396662333363303331336663
+39616535323961393937616435613965396536346161376232356566633563663562373337653564
+62393330323364316638326332373034656465343730313836376236366563393939383566393366
+64383661623336333539653766633865333561313036303737373832353332353733666230353731
+31303964353734376365333230356435656266303436356439396630626130323136313865643461
+63323862616165616164396431656538363831356132633336313434303233616662626130303262
+31363432633961346662343136326237656632393630373739306262363363643862623334623262
+33333731333234386164306332326563303432613933306263356264333337636236343233663763
+34623831666432396261633830363438396538356636396439646462343662343261323835336234
+30373632366261353838396636313732396433336261396633386565626662333263646136613234
+31376536363562633738306439656465656133373434393830303931376236383137633538326165
+31633938623462313464616530343137343766373130316635313062363336623263376663393864
+33346264623764633034376263616638653534343866643833353531623335656232656434313335
+37333934376266396164366464303063323837613136306539393230333031663433383437363862
+37336636306334633732633966333465353131653564623130386338323433376164663039646136
+37393263613062646239336261376231363131386536623732346663343739383732303462373961
+32366162626364333136363639353039373637356163346462393062613236643064383339626164
+39383432666265366333393935626537303036656135623564653463393766373562316637336664
+36336264346338623235343031333266623838373363666138376633653761623261353332353063
+61316535326664653138333866353838626134303064356263613930343632643032633763613932
+65653464306364613163363064333466666534633635306431666334646361623438383465623135
+38343736313131383662306662306632336561333864636538623664613864623863353837313831
+36316463343263353563636436623531633437656433353433383630313736306365313233626337
+30653436386338666664323561653663623236356262616336313739666465333765303032653966
+61636435623464383637343537326162643733343763613736313665623162343633313366623738
+32386564623134663636663136333934656562313064303964333934656132303436613262333235
+37616430643239613731323230303765623134373864313030393436666237653861383864376265
+36643466353839363436333536356534306238386161303437376335343633333966363261646630
+38386563363937373366663663383561366630633165373838653364326339306331613038663034
+63623832353739663765613861623364353030653139643632656637623462653532663366333165
+64313365646430663632653862613238333935633262633130303031656334303466303863666639
+61363835303061613365663635346530303939656633663262666635333661623132623166653335
+63323661343334663934303163313665366235623466656235613431663362333238346465376531
+66383032386363623238323065323935663533376131393433636635653362303662616361343166
+30613934383633646131353333333639393138633962376664393337633461323631646362306339
+65626436383832326236386434366236343134613532643832626131346438346639636662633138
+38313239613239316462653736643036336132623731363835636332343331633434643362373934
+33313739303137353261303330643663646233386662323366306266356531666533613864666431
+36353264313264643931346565663735363139626435636264343836343362336634333532356366
+36656361363239643965636638393533666435656666336339326564646437396535303634633931
+33396335333466323266363366383063333633393235366239636535626461393531623736323835
+35346234373938343530373139643232623136666564373661303432643261323064366535643730
+36393334373431303435303330633132336263383761396430633638326565636630353464343834
+36333761303934343538313037336231633038383333326233383434343366366361396665366536
+61316563663037616533353437393332386537646135363934613164353665613335333433353061
+66386362393338396564323738346332383138383264356137316563353136626633643965666139
+66633733643535393434333436333964313338343633616137373565323633346533376664313832
+35393063353737656235363738616437363764316163336166313265373665656133303939386336
+39376139616239343665313566313534323330353662636261336533393038633336306535616535
+33363837343864336636366332633164333165366634626634373039313130366335336136356666
+63383665396266343239336663383931613736346261316530333065623466396266666333663064
+39333134316632376636323263313631336263373739313037613363363932633831333430393266
+30346433633830626234323739323430326264326132383337636165363134646337333833353732
+38653938613633643030376261363630643833396338653864383130623136303663323236363734
+33323032303336366263366530666566653236626436366261663834663364633637643061363765
+32653734303931623737653531383731396565306161323262613261623564373430613035376333
+38613738373835376337316261333661343437373734376130646538643431333337346439636132
+31363738343631393439613534396432393464303234323835616534363062633564663730383134
+39643834643366373362303836626634666538333163346165346163316366613633663939333833
+61326531366232366162343238616562376239373137633565316237316361386135643333333463
+32363764623038633133363566333537333631663737363764393533333538343564346539323631
+35643162386631643061386663316433323961376662663938373466363639336431613464653662
+35323230623661376630633433326133313735343564373461643465633064333331313231643835
+35363935613765366532653433333230396338656333353334653766306566306430636563333238
+38353235313963646132323331613239626436636461343532616266383861386233396431353334
+64306533323762613330343361313562313138643764363661393366333032663865343130323431
+64623561306534666332313637366364306335313961363333363566366633393535366564373037
+66386233656635633365616266663630366330393362626333653836393438633432313035613263
+34363633306364633138623932396438326535363165363763313763383533653732663138343364
+30633737623837323732303336373465396339323764363230373966343538306332393161363462
+64613639333530353937643064356534323434613930663834613730333630396232363862633934
+30326662643735303039336636343664653639383431326533326333396337373030373931623238
+39396662373339386237306339653465633164373038653635356134666539363835613566353664
+32656236363231393839353264363265333935383432376531353566303330383965613161643030
+62643332633163363864313539373833326261313230353362376333373865383661616565346466
+65393531623966303065646262323763383933366462613862343337656637393132306263643838
+38633233616330373232306132333132353537373130626162636636646236663335623938386666
+63623264346465653830376535653739333335666135626533303038323938396437663130366633
+39346164626662303539623337303932323836353261333332636365646636336264353661323736
+30663839396333343535663463643831666361366332343835653130303239333165393834663032
+63373764326634643934313566343033323562636561616265383662343166383033306430383765
+64333866363738396139373536363739613366333539633330353937336430343334653965343566
+62613262613832343762613637376361326265363266663331336231373138613835663235343636
+32616536653534386534346233356162613062353165663362336535376531363436306532323838
+37373435363739306132653732666233306465626339356439393835373138353533383064383466
+34363065643962653362343961313166643562386237613936326561333832366538656261313330
+34613464316664353031333933646137643434346262396632623735363737313466343264646562
+66633365393637663034643533646164333231363131396338373637303336616436383935326630
+65303734346138313336616632333963393232343532336562646266366266393038623933306230
+35613939616331326562633038386637333430373163376462623730653633653339303136353366
+66626435336466383263623434646634663931336337626630313030626439646338623136663063
+31306462613165346631373737656132346466383531626337316464666462386663396436633635
+34353861386164373265333764626362653730666338363437633962643866613332653731326537
+39653730323239646666636432356237336565383936373862383861653936393134373631303837
+61643536323439323937363337373963313765393236306665393262353335636637383033316165
+36393230383665623431393665356663646633633462636364616236333831633665326662613262
+61363630653039333137383662616333663430656362323665396633306236333538383763363362
+63626234363765316534613431313737323636383932623731326132306362633538643561656630
+38366163623538366331393636373834383865626439343830626235643861663639323832386235
+33343737613961633561313066356336636562343264386665333438653231313266646363343936
+63393965373964633436313766336564383633333234663632663938333666626266316163353138
+64623635646363643738343730616633663237323265353239323965373365616435343163623664
+36386166623864323737386634303138653735353566653435353537363238656334646437343362
+30633363303335613731326162303734366635623439373562626364656262646135366163303566
+34363762636637373064353237626435636365306530306235316438633662393031613833343461
+38666365626132373262373235643936313033346136613736633531383564633363343333323064
+39323537386234326438353630646363343730333039653134666266373532656539393134643935
+30623566363233313332323534633765326531323833373261353164386236343161366265396365
+64613163326162646532306239646236356565353831316331353932623634303565363039313839
+36373564626662373939643539373762373033333938323432636331656336343362306333363633
+34366635303635616136346138323332353738396632623532333465633064326431303463393161
+38313663346534633831636332306339623330633032363938386164373266373839626630636436
+36366666623962393763336334666430356166316266343930646532653763333563343365366137
+35623435393565333238366635636536303761323061363365663338343038663736343262383364
+62623632393266613163396665306131653830633565396635623561333639613334633633653337
+34653936366435373765353239323164663962356165373438346230323837336434663663303838
+62386631313363643365303338656335343630663530336131386263343939336637313961376139
+61643663323966323231646639613731373465666465613133643032386566376632363961336666
+36303836373866393633353762333931333165626335393865626434313164383461383035656136
+34313334303639653338373663653539353139313537633032323665343066333733323066376337
+63356635636366663730303235386239336361323132363139353763376434386434613033343064
+61313363353131346635373030613737386433663035643430376134616530613933366238633332
+36666630343665333432386533393334653837356461343566363432613331326661613062386231
+65366162656230656436313439303834663064303039323365333732666633383935663965633532
+32306466613630353938306435646234643539396164653230313232653531383863343962663963
+65616663386438633330383939373535333533303265333831666366386430363761643161333866
+33303565666466643139343165366238393337336361643564336165636161313064643936373038
+35326433396336386562383535663363386439623363636333663132343763386262346265396161
+31636134373138666336316261313739613736333561393838386531303365336538646461343835
+39323039396435376362323165363231663966313132356537303866613133303336653539366565
+31663964366462623135343333646136356135323061663631646332366539353264373931323061
+63663863633438653533353861336331306163626563316663613061643935653436346239376261
+34333765343030633337626661323133343938643835313166363739393137663766633737396234
+35663635646338333664376539313937636233353634333466653535373631376461303764616165
+63323065666435613961646632323162613739613831383665313366663135333861663961313436
+64323165333362303933326132626165623539666638343462663634633731666665613331626436
+35326538643264303436386564353232303663303966663333366239386332326335393061373434
+64303566616466356530313062313062613431646434656138353662363039633030336239376462
+36313331643232646362356437393466643231346435353965323933643132313466646665656338
+32306437306530643362633932316465303966316663613030633634353436363762333235623231
+36326438623262366436396661306263373462656363383338613965663365333239613461633332
+38393532376663623937613837643832613638386166653033623435623161643430363563333338
+66383039303438333133633266323563326665316439626266643336306237646534336666336336
+61643638663735633735616639616335386461366132303562643063313563656661623332643933
+62383238363337376134386139383730343633383530353764626130343662343363396636306364
+61393337326164663939383535633263623030336131633037333837346134343463323238613535
+30663833353037333764613563636666363337623331393632363033313434366131343738303836
+33316132653731303366303163623566623165643834393231653333623063333936333435646537
+39313961303236353736323938353035346464326534343939616464366162666431643135353534
+61626466616665333538393234653138623235393265323361336437343130616564643431373231
+34356131656439343434363464363139653363653166336236386363616161363539383437666535
+35346334343662393530666566396530386463633130303463316330376265636532306164323139
+36663464363965393036376631383937646164366333653935396465633935646232333064346437
+64663164393234356638396233383664326631393232656362313830323939653033323034363739
+32336135353134383439633838333064393663343936633134623633316234393464623066656666
+66323635393639383838323437366137303437336239613664333738323239646362346633656537
+64663238653437633239313337616236363561306632373836353632393235646162326236626138
+66323162373335383866373933323339373637383838373138646566663636393039333363383163
+34623738323865623636353430353764336162653866386662613465326433373461613739393838
+37643638636133636565363239316231613832393566633436363964636265666365666662353465
+64663362313830663663333266616634386336353333343333373136313530666333373561356462
+34376237373962303761363436636664643666386439613334376632643231633461353834366534
+63313832613063336430323061653930646435623462383237396561626131643733333137333431
+35393739313462386262386435643365613630353434636132666336656235363231303535353362
+39393339656565323936326538613662623333326165643936633631663035653338393731616266
+31373534333266633839396562383230313465306534623639613234323335313130373866626462
+61363337366666313563626636653933353431396465303137336238663639663634393661386433
+37616136316166353238636637393230363435313261396164366166633736343931383764383038
+64363534623266383965373764323431373637313633613331643862633865353761633334663461
+65656130633436366366376236653665653436343465643437646138323036366262643362633962
+65323535656262393234613430646462366466383232623531653434326230393838376132626231
+62393063356435653464623132636231616335323066666366306665643330663639393365396366
+62303137663338353230323866316463316264643636333439323734313538656530346136353636
+36313663613462623831616163366332356666326536333731623932326636323835643631366639
+33303866633239333065653633323030666234323132346461363434626231366531313164326333
+38613364616237653463616230643865366161643362313864303537633330353633383037613637
+30386434343162623533633266393137646564373137643030363033623163656339653762373934
+37663238333366313836343966336664393733316262303239306138663633643362363932373636
+39333333653965353463643261376163396662666166663166306266383762376565386265316633
+35366465646138306433653630383839323237666632363236303930633334343535383862323534
+34393434353865613561326234623431343335353462373035643034663937633739636561663261
+38336261366565623631366335323163386338333035616532663430383034363762383632636630
+61666462383338626166636332323664313866353930656664326365393933626538356635663262
+37613564316466326436393463316662306433343262366137663730326530393837646163656166
+64373164353962323338346136663430386362383066323065303234363063656265383938363231
+32383835656265333731363566353538636662323537353964383736333233343961336235666136
+61303839306539326430613364393036353238303039343135653362613862643535363066626439
+31623734353333393063663961393262343963386334633536613338313639613636306436393135
+63303834306331333661626161393266333732663433656439363233646662366666636661633361
+35306435363064653561636565376261336462613532343337633630656530636234626465653734
+38316265636536313864626639663639326630666563643730336166653837333235346562363438
+33646366633166356461366461346338623365336235326663306535313833303133393635663738
+31323337353766653961333263616661613266373565386639653939323065653238313233366632
+65623963656334363562366264613364326131633933623539636335666337636334633236393239
+38663362393866306166363634306363316531353765613162303965333663336262643439363264
+35363365373439636139326539393438616464646263333035346362363334646535363464633439
+37653664643963616465633834653538653363376134323161306536326639636333653762393332
+37386637626239356431656230356530396261653664313062626634363234626136353664323765
+39613733306634656535346437323535646666666165306439366531373833376564333966353037
+32363566643565383064626538313437313331626537373965373464363232363032363761383338
+61396461306438303033373338373665343237363862373065653563356663396438366439316535
+61636565653065336632323765646165643231656565363533363062313666306438663039313833
+31336166666535393934303232353338303438313233663931633934663462616634366161363066
+61393465646531316339613066313238323066626464333764343964356461643966316432613331
+37656138633331373633303933653536303162373362653163356161323266353837343835346439
+38623733633863383464346233323363383964373730643730333532333936643966353737353234
+38643630643837636439383034663536373566383161653136656566366632366166323538386366
+32343566633439393933653030346337313138656362346666383430333265323031626363623966
+62636239323131343037653836306133643066316437366432313262646638363363656339386532
+61663937356533613934316265633430613531386632633636633362363263633230393064383039
+37636465346236393031633936306536383832623932376234323762613766356235303366623766
+34353336346330383466376165613061393633626264356536353232643863653830643437303832
+62333465303436326139346535343439646563353536363164363264633565666534333365353438
+66666231356535343333636266346436656361326436653339653663356166313263356465623832
+61313961613166653838313630383132316661656231323865353365343161356561316630323834
+62363563613563626336396538376461346532333163326333396630386330663237363731383337
+32356636616662653062653031366533643433613563393565373563333436396434363864663766
+63646339663566333735323834306330323931623462356663336538373163356461656635616638
+30336666663735363261376133663936656635616235353361343764623636393433386530356335
+34326635306131623733626663353333363466303437383331316466356265343537323761323133
+36346631666131363963333465623437653635356464346137663864306561393836316432663636
+33326164393030663137363165643531346332383136663461373934323531373362363535313235
+62323037316234623131663434323666623032643263343630663362373862353438616262333530
+32623662306537333131376134363639303831313839393462656138376435646531386639613136
+63646331616561313632323165316561383866643238303636663066303766633931633638373761
+35363564363537326430366561306235663332333539366566643338643264383338626232363032
+62623031393334616530366634396434393439623036636634303134653935633466326439613238
+38396164633031326438316336386439323034346265633734303332356363323766326437666262
+64666164363630643862356563653839316237636465363738666632653931363761393463623165
+36643939656362323434363338316462303462376264396461653739313630636230623261653032
+32633438336361373439376461313538346332363939323531613832666237356335373864396331
+32613965383630626231363034623836643465323162333339613531306462326434366333396535
+61646638343133333633316232386563636535313035623634663362646130313037386366396262
+65376663313938346432303633393835326339646131666466643066333863666634626430393165
+30656464663132613765303365353536346135646432366364306433326332393163323465353235
+30626130353065356335383833303361356633663931666433386233613365373661333365323163
+66663630373861323039663534386434623337613631633536626465383739336338303465373163
+32313539356530663933643238323231373266336565326464623062353330383732626339646566
+39626131356466306465626363613230306463393133356465386639653039353664363130656161
+61636134373336646338306465313333656138636562376364626661333038386430383930663936
+39353930633465666232363966643931613635393939346537333530323566633163353365323235
+36353632656231396265636464383934316262633033363234663636373633376166313961666237
+30326664303133613534656266633736626365633838633066633538346434323939393038373864
+62303462353765636366386461666539346230653262376562326534623465336531653264623030
+37653330303831346431353430396334363030356539383733633565616364663562306633383631
+38316461623861383561343738633066353064623966333866646666613933306566613330636465
+30626130613739643032373464343235613761306337393464373439376261376664643966336338
+38613661343565323137353132346135386562303234396165386534346663616334666433666363
+37323637376532323263636531333539396636663836633636373930393639643161633662366533
+32656163373232343162326636313864313037653061366436626330313063336134616466376239
+35393930636639346637333338383338623862626338396263623436633230333736633233326239
+37323664613663353964376165646335656635303432613462333334346634353963333964333566
+36343563396532613864376131396465336632653038353737346561316533363234346532323562
+35346437653735643337336630303532653638373736346635346166303831313862323630386564
+65623939653739353266353036316662353331313139363866303566653864373363616533643438
+62336265343266663336656363643663306536346639666532656433313131326230303261616535
+35353130383565643766333137323966653636336435303266393333646531373235613065353230
+35363261326437643933323131636635383136626637363064653365396463383134336630393864
+62376234383861663932383862396566366236326239363033306461316231336234663837343966
+39303238303362643130313462376565633237366431623466373337373332303765366338393363
+34353063656236633061646662613961663064313666393931323261316263343234386634626132
+35656264663836343062306662386433326335396639663662373833396530386437306466616230
+31636139353938306162363362663565353432626265383764376534336634333838356566386337
+62616666343930346638356634326439663562663361616561646630636563326437336361366538
+38363038333337393036353666373630343431386363366434353936663862306631613036393437
+31626331633434386635386136643830353430636462643462373966663135323637626139656532
+65353736613939303430376563613233323964333435646632313131653461356430613339663762
+33383335393137383864633039656133306261306431353134393233613536333362376634363136
+31366537656435353534373238633563313466366439346239663935653930373134393632653730
+64353130636530323636363366383066383264373162626435636466356334373765333837366264
+37616264383834366666303839303931366462663533663630346361643830616631323932666266
+61366666613838343966383831343966613633333038346666333833373135393065376132363261
+39393866326139336538353763613531303732643839356562653965636337333938616462386166
+37333663346563643035636237656166366337306137663262363636663762373138626162363363
+62393361653363633433643364643437623531376563303563636434653238333837356634386535
+32363039643734616633646339666334303766646564363930383632373335643665363931646266
+37613439316337343331373534376536306237613737653937666231303861346666613235626263
+62616565616366653562383839326364346234363839626230653631393763363530353938636563
+33643763653634643633366139663631623536336435656366386563623666356438383136323736
+61346331356565656638366661623864383232306338643862643266393031323363306565643964
+36646634313234363766366264356431393261333038363134663539633363653463376166373364
+33653861393461643434313461386363343434386536623862663738636363636139616538626664
+64626565623635336231646433653963636164646633613638323535356536636366303434396337
+39373861646266656535393362356230346438616337626463323261343737646365376335353033
+36623235363935383331383765623435363931363834336332383563393239373865636436623239
+64353133303630353965336136343833343961303034356535656465326335316630633735373236
+38626333373666616238646361373835626464653631356264663761303133306361643030616162
+36376261303533326363353538316231363732323733326239313730363836303131646364383665
+61383036633335653161636466656136336262336532633031336537663737623066353263393431
+37613931346165333537303831326464613762333338656238313032323238303036373538303039
+31316366623239643036303833373437376566333132323031636338646463643430343435383462
+63336435326336666435643763326561643539323734376161646233363165373636626462663765
+62386237386639653335666532366666643332393739393466393637306364623962333333316638
+38643638653265373238366437656233366330646438636563363165303761643235376264393035
+33376366623063396163313562633533303337326635643334353334363735346631633630316331
+66316434353330313937666564336332333561336535383662396464376239313363643732666335
+63666464653265366139653561633466646131613435373061313239356237346231666335336337
+35376536303064376261323131316333303031366434313366306364316535396635383861376236
+39336632343466396430323634646133653131633862343030643533643533336462626466643538
+66626232323637613135623562666461313137383332313336363939663535303233646338383730
+37356531343730636435363036393038633535303663666564653936623130616263376463646164
+31346237396633653366313434376333326430303135333839623864616265333338343534373163
+66383265386534366561366465613166323930303061336330313532386534643632356337383733
+66636633663938613861366436363763316161623234316664386635343739376634393737373134
+32373537653132643166326433643634666435356161393062313439303536363263376335613062
+35313664636638623134353530386466633866383438343633396665666235613538633736396565
+64363133373834643337393134636232303136363138346364656436313239313838383335646365
+31386634353236356435316132333565383131383534643830663036356466353432356636613537
+61343162316562633065663035633337373262353365366335356137653233336130313436373633
+32333364383735613633643934633634346238643463356665613561396366383563646333643963
+32666438366563386165353236353238663932616138376332373035353731393563346335356437
+66346365353066313030643936343837636538323739633365306361303164393039363966373037
+35316433326330643633333939316361386137366261343866393238383663373232643465303262
+31636663386330323066326631646163353437326538313065386364666530663962313062383033
+31666231326364663739656134313066393061313732323666626630613532353830653663316330
+63666532333236663730333038633765383834376364356132656539326230386636326365323762
+34613539376363626333653364353835663833363563343765623564386138383765646334663238
+37636135373861343663363231323032636562643533386631616634356438313331363830613664
+35666233323334303830636134316630626435616365653137643735336637613235333339386639
+34303164616531353538343139376365346366666533323038363331653664383363316331323735
+39306164393261366536336639313738626437343563353334356534343431306539376464333333
+30666135333331303831383633306236343766363163323030373838326333336639366631643436
+34623137303665653365616530316465666632653739306134346335653231313839336135363035
+37333663353464653237356234306234313431643535393163303137313335656131333433336235
+38313632366463323036323263383136623161353164313833353466626163306335613737623536
+63363265306130643737646338323466623235316530393561386439393965396566643333613564
+38626639356432613538613235303136643335663365323964306166633631356534306135656138
+65303166396263373631343562643939613539383436363838303334643239666333386335323535
+62313962323939613538333632633131626236346634386366623632323763636334643835303165
+66373337356135663065653037653963653337376332333037663437393231313934636134383063
+34326637643466623163346331336233653365383437623731323935386166666236353866666663
+65633139663965666639653238646661316136643634356134653862346533653038383837643030
+31613330363535623639346462363661346435666334373033646139316639336134646436633861
+31633366363030326164386437343630663065626138653462303063336535316665326438656132
+37386561653736623730323632373965393361336132323837353035663339313031396264376131
+38323066346564383962336437326232643463663538336264343233346636363837616566666264
+37373832346533326266333133303933356165383535346238613364343933336465356162663161
+30373133313431653462326362653831356439323463636565303933616561663235373534333334
+37653033623039663035636237656166326663396462383461633761363837663463303766316335
+61376562363834653134366639626566663561303637616265363861383561613963663562613032
+39666630613932313131303931613264336564343039663065653136346262663535613061623336
+62333664343766666461343537366336313532366631303032663165333566663562663034343463
+37306131363139366135623432613530663962336433346463356563623765353430643065303561
+62326561633035343437346163373361306337386564626238303663366234653165336263343162
+36396262353737396634363134363765653435373137383439366430306538323333613063326338
+65313265383832343130336561323232613532373733383135326535353036313330623663633966
+65373265326434663564636263343765346134346332353338313662303165316262653865316537
+37383937353364656561373961626133626663393861396439656331653265303234343534666533
+31633735353531666636303739316266616539613730383963643661363733306635353833653634
+34363932613833663762326665363937623339313866366661343963613263656437303637636331
+36313630396533346663333133643566353165386331326664633036386530636266366332633738
+33653037323364393930373936396361656563396435656464633037653235393261363561356130
+65336135353337313662633335373362376263326666393362623330623432316534303330353263
+38643466336337316233623739313161343766343862626637343135643035626365363030373265
+61653031643233616432396466383134353539353864626631326338643364663833336631613964
+33323437333632616366623133383966336230646537653761633064306664373566326430646264
+30626464333161653063636235393734653232633865633565323139383231616135333736303064
+33323966373734643930393237653166393132333339376432623963326462383233363930663962
+35613232366161613864666133616564376330303631353037373839386434313565363531313539
+35393035376636376637626533663737373336346237613762313536373863383432303734396633
+36376166316236363035663966373061346530336239646236373664316239633830666534316639
+3936
diff --git a/.talismanrc b/.talismanrc
index 050a68c90b..2e2e512163 100644
--- a/.talismanrc
+++ b/.talismanrc
@@ -16,23 +16,23 @@ fileignoreconfig:
- filename: .github/workflows/release.yml
checksum: ffd104ff02d60abf3183694209c5191a0bb7479ce37d8243778275351b4d2228
- filename: .infra/.env_server
- checksum: 6558cb49387af378d27ce4f16134137df3506e001b388bf04f2153a3d74b71a5
+ checksum: ea90495a7b8a9ba9a34adc380228d4ad6f0336d1685488aaacd228bf3bd11e18
- filename: .infra/env.ini
checksum: 60d461050d64c0b87831d6918a8696a8dd2f69cd86b4e6d94b40c3b7b285c320
- filename: .infra/files/configs/mongodb/mongod.conf
checksum: 718bee5f44edc101636be8f11173ede5b728f2858abc3c26466ff9435f0d11de
- filename: .infra/files/configs/mongodb/seed.gpg
- checksum: f3da269202d63aa1ad66b8eaa148076cf0135eec6b7fabe2394fcc3eabb466d2
+ checksum: 26a2a97a0624529d3f179b272edeb32959e0da5e1dd7a8854286c65c2b75143b
- filename: .infra/files/scripts/seed.sh
checksum: ddafc86248e8fd5f7c24ca5a62be703083f7704395f17fb7b43bc8e44227d561
- filename: .infra/local/mongod.conf
checksum: bb2ce0c27102259a5fa39da1fb4460af9ad6ad58adc715312e53dcd69c8e6be7
- filename: .infra/vault/vault.yml
- checksum: 55b3dba68f43aeb480505c508a3a95baeb027f6699421f8642624d16ea88890c
+ checksum: 094824c6c1f794ece05abf61220465e1327f71d932e6eeabfbaf6f2094e5d538
- filename: docker-compose.yml
checksum: 8cdd1da6c1155f26b417a27e26311d4f00b7d8bd6c21f1f86c1c7cb3f0599e6a
- filename: server/.env.test
- checksum: a5416822ec3c607557a69a8f20014de9fc40d8a871884bb79cc3906078c4ef15
+ checksum: 69332e43a85e702b93d00e62edf109c3b22189660de33e50e766ba15ea58f8b8
- filename: server/src/common/model/schema/_shared/mongoose-paginate.ts
checksum: b6762a7cb5df9bbee1f0ce893827f0991ad01514f7122a848b3b5d49b620f238
- filename: server/src/config.ts
@@ -54,7 +54,7 @@ fileignoreconfig:
- filename: server/src/security/accessTokenService.ts
checksum: 2183e326a88ae3c10193a7033ab5fd421ce576cd1e234c323df247da335f74d7
- filename: server/src/services/application.service.ts
- checksum: d3ae58c0b6a9d42164b69dff4573734a6d854282974c4a10d065f2c2443f144a
+ checksum: 38021ae663db3a146c848a8d691c0c1bc9bb262a80a6f2dedf9fcdb372eef3ac
- filename: server/src/services/eligibleTrainingsForAppointment.service.ts
checksum: 52bfe91cc0cd07121cad6dc9490a7345dbbbeb285f11fc844a8dd8eb74fe310f
- filename: server/src/services/userRecruteur.service.ts
@@ -121,6 +121,8 @@ fileignoreconfig:
checksum: a50177afa593bae5707bdba29ef27b8f2ed0bc58487491bfff580e7e1f422243
- filename: ui/components/espace_pro/Admin/utilisateurs/infoDetails/InfoDetails.tsx
checksum: be2ad6ca5c2bd36d26cd7aebb33ab876bd32662be3735fee3b167325c284ccff
+- filename: ui/components/footer.tsx
+ checksum: d82b5a7d6905070fb32383864566fc16eae4797ca18f82782fb32c95a0d50369
- filename: ui/pages/accessibilite.tsx
checksum: d6a7c57500f9de5e47e305f89435b21d717f505acac7b45656931f7ecdd0fcca
- filename: ui/pages/espace-pro/admin/eligible-trainings-for-appointment/search.tsx
diff --git a/cypress/e2e/create-recruiter-account-manual-validation.cy.ts b/cypress/e2e/create-recruiter-account-manual-validation.cy.ts
index e5ccb6fa27..23cfc2dda2 100644
--- a/cypress/e2e/create-recruiter-account-manual-validation.cy.ts
+++ b/cypress/e2e/create-recruiter-account-manual-validation.cy.ts
@@ -4,8 +4,8 @@ import { FlowCreationEntreprise } from "../pages/FlowCreationEntreprise"
import { JobPage } from "../pages/JobPage"
import { generateRandomString } from "../utils/generateRandomString"
-describe("create-recruiter-account-siret-inexistent", () => {
- it("tests create-recruiter-account-siret-inexistent", () => {
+describe("create-recruiter-account-manual-validation", () => {
+ it("tests create-recruiter-account-manual-validation", () => {
cy.viewport(1271, 721)
const email = `cypress-manual-validation-${generateRandomString()}@mail.com`
diff --git a/cypress/e2e/create-recruiter-account.cy.ts b/cypress/e2e/create-recruiter-account.cy.ts
index 7a9f1de848..309eba5df5 100644
--- a/cypress/e2e/create-recruiter-account.cy.ts
+++ b/cypress/e2e/create-recruiter-account.cy.ts
@@ -4,8 +4,8 @@ import { JobPage } from "../pages/JobPage"
import { LoginBar } from "../pages/LoginBar"
import { generateRandomString } from "../utils/generateRandomString"
-describe("create-recruiter-account-siret-inexistent", () => {
- it("test create-recruiter-account-siret-inexistent", () => {
+describe("create-recruiter-account", () => {
+ it("test create-recruiter-account", () => {
cy.viewport(1271, 721)
const emailDomain = Cypress.env("ENTREPRISE_AUTOVALIDE_EMAIL_DOMAIN")
diff --git a/cypress/e2e/send-spontaneous-application.cy.ts b/cypress/e2e/send-spontaneous-application.cy.ts
index 54a80df5c2..70f8d96dfa 100644
--- a/cypress/e2e/send-spontaneous-application.cy.ts
+++ b/cypress/e2e/send-spontaneous-application.cy.ts
@@ -14,6 +14,7 @@ describe("send-spontaneous-application", () => {
const fakeMail = `${generateRandomString()}@beta.gouv.fr`
cy.viewport(1254, 704)
+
SearchForm.goToHome()
SearchForm.fillSearch({
metier: "Comptabilité, gestion de paie",
diff --git a/cypress/pages/FlowCreationEntreprise.ts b/cypress/pages/FlowCreationEntreprise.ts
index 405814eeae..c7a583d93f 100644
--- a/cypress/pages/FlowCreationEntreprise.ts
+++ b/cypress/pages/FlowCreationEntreprise.ts
@@ -50,9 +50,13 @@ export const FlowCreationEntreprise = {
jobCount?: number
jobDurationInMonths: number
}) {
+ const typedRomeLabel = romeLabel.substring(0, romeLabel.length - 10)
+ cy.intercept(`${Cypress.env("server")}/api/v1/metiers/intitule?label=${encodeURI(typedRomeLabel)}`).as("romeSearch")
+
cy.get("[data-testid='offre-metier'] input").click()
- cy.get("[data-testid='offre-metier'] input").type(romeLabel.substring(0, romeLabel.length - 10))
- cy.get(`[data-testid='offre-metier'] #downshift-1-item-0 p:first-of-type`, { timeout: 10000 }).should("have.text", romeLabel)
+ cy.get("[data-testid='offre-metier'] input").type(typedRomeLabel)
+ cy.wait("@romeSearch")
+ // cy.get(`[data-testid='offre-metier'] #downshift-1-item-0 p:first-of-type`, { timeout: 10000 }).should("have.text", romeLabel)
cy.get(`[data-testid='offre-metier'] [data-testid='${romeLabel}']`).click()
cy.get("[data-testid='offre-job-type'] [data-testid='Apprentissage']").click()
@@ -84,6 +88,7 @@ export const FlowCreationEntreprise = {
},
delegationPage: {
selectCFAs(cfas: string[]) {
+ cy.url().should("contain", Cypress.env("ui") + "/espace-pro/creation/mise-en-relation")
;[...new Array(10)].forEach((_, index) => {
cy.get(`[data-testid='cfa-${index}'] input[type='checkbox']`).uncheck({ force: true })
})
diff --git a/cypress/pages/FlowItemList.ts b/cypress/pages/FlowItemList.ts
index 2f89819b91..c8c58e4a2c 100644
--- a/cypress/pages/FlowItemList.ts
+++ b/cypress/pages/FlowItemList.ts
@@ -1,7 +1,32 @@
export const FlowItemList = {
lbaCompanies: {
openFirstWithEmail() {
- cy.get(".resultCard.lba.hasEmail").first().click()
+ cy.url().should("contain", "/recherche-apprentissage?display=list")
+ cy.url().then((url) => {
+ const searchParams = new URL(url).searchParams
+ const romes = searchParams.get("romes")
+ const longitude = searchParams.get("lon")
+ const latitude = searchParams.get("lat")
+ const insee = searchParams.get("insee")
+ const radius = searchParams.get("radius")
+ const diploma = searchParams.get("diploma")
+ const builtParams = new URLSearchParams()
+ builtParams.append("romes", romes)
+ builtParams.append("longitude", longitude)
+ builtParams.append("latitude", latitude)
+ builtParams.append("insee", insee)
+ builtParams.append("radius", radius)
+ builtParams.append("diploma", diploma)
+ cy.request(`${Cypress.env("server")}/api/v1/jobs?sources=lba,matcha&caller=cypress&${builtParams.toString()}`).then((response) => {
+ const json = response.body
+ const resultWithEmail = json.lbaCompanies.results.find((result) => Boolean(result.contact.email))
+ if (!resultWithEmail) {
+ throw new Error("impossible de trouver une candidature spontanée avec un email")
+ }
+ const raisonSociale = resultWithEmail.title
+ cy.get(".resultCard.lba").contains(raisonSociale).click()
+ })
+ })
},
},
lbaJobs: {
diff --git a/server/.env.test b/server/.env.test
index f1145b2743..101d90da77 100644
--- a/server/.env.test
+++ b/server/.env.test
@@ -54,3 +54,4 @@ LBA_ENTREPRISE_API_KEY=LBA_ENTREPRISE_API_KEY
PUBLIC_VERSION=0.0.0-local
LBA_FRANCE_COMPETENCE_API_KEY=LBA_FRANCE_COMPETENCE_API_KEY
LBA_FRANCE_COMPETENCE_TOKEN=LBA_FRANCE_COMPETENCE_TOKEN
+LBA_API_APPRENTISSAGE_KEY=LBA_API_APPRENTISSAGE_KEY
diff --git a/server/src/commands.ts b/server/src/commands.ts
index ea2555ed9c..d2323fb7db 100644
--- a/server/src/commands.ts
+++ b/server/src/commands.ts
@@ -211,13 +211,6 @@ program
.option("-q, --queued", "Run job asynchronously", false)
.action(createJobAction("recruiters:get-missing-address-detail"))
-// Temporaire, one shot à executer en recette et prod
-program
- .command("migration:get-missing-geocoords")
- .description("Récupération des geocoordonnées manquautes")
- .option("-q, --queued", "Run job asynchronously", false)
- .action(createJobAction("migration:get-missing-geocoords"))
-
// Temporaire, one shot à executer en recette et prod
program.command("import:rome").description("import référentiel fiche metier rome v3").option("-q, --queued", "Run job asynchronously", false).action(createJobAction("import:rome"))
// Temporaire, one shot à executer en recette et prod
@@ -317,12 +310,6 @@ program
.option("-q, --queued", "Run job asynchronously", false)
.action(createJobAction("pe:offre:export"))
-program
- .command("validate-user")
- .description("Contrôle de validation des entreprises en attente de validation")
- .option("-q, --queued", "Run job asynchronously", false)
- .action(createJobAction("user:validate"))
-
program
.command("update-siret-infos-in-error")
.description("Remplis les données venant du SIRET pour les utilisateurs ayant eu une erreur pendant l'inscription")
@@ -365,12 +352,14 @@ program
.command("etablissement:invite:premium:follow-up")
.description("(Relance) Invite les établissements (via email décisionnaire) au premium (Parcoursup)")
.option("-q, --queued", "Run job asynchronously", false)
+ .option("-b, --bypassDate", "Run follow-up now without the 10 days waiting", false)
.action(createJobAction("etablissement:invite:premium:follow-up"))
program
.command("etablissement:invite:premium:affelnet:follow-up")
.description("(Relance) Invite les établissements (via email décisionnaire) au premium (Affelnet)")
.option("-q, --queued", "Run job asynchronously", false)
+ .option("-b, --bypassDate", "Run follow-up now without the 10 days waiting", false)
.action(createJobAction("etablissement:invite:premium:affelnet:follow-up"))
program
@@ -531,12 +520,6 @@ program
.option("-q, --queued", "Run job asynchronously", false)
.action(createJobAction("referentiel:rncp-romes:update"))
-program
- .command("fill-recruiters-raison-sociale")
- .description("Remplissage des raisons sociales pour les recruiters et userRecruiters qui n'en ont pas")
- .option("-q, --queued", "Run job asynchronously", false)
- .action(createJobAction("recruiters:raison-sociale:fill"))
-
program
.command("fix-job-expiration-date")
.description("Répare les date d'expiration d'offre qui seraient trop dans le futur")
@@ -561,18 +544,6 @@ program
.option("-q, --queued", "Run job asynchronously", false)
.action(createJobAction("recruiters:data-validation:fix"))
-program
- .command("fix-data-validation-user-recruteurs")
- .description("Répare les data de la collection userrecruteurs")
- .option("-q, --queued", "Run job asynchronously", false)
- .action(createJobAction("user-recruters:data-validation:fix"))
-
-program
- .command("fix-data-validation-user-recruteurs-cfa")
- .description("Répare les data des userrecruteurs CFA")
- .option("-q, --queued", "Run job asynchronously", false)
- .action(createJobAction("user-recruters-cfa:data-validation:fix"))
-
program
.command("anonymize-user-recruteurs")
.description("Anonymize les userrecruteurs qui ne se sont pas connectés depuis plus de 2 ans")
@@ -593,6 +564,12 @@ program
.requiredOption("--from-date , [fromDate]", "format DD-MM-YYYY. Date depuis laquelle les prises de rendez-vous sont renvoyéees")
.action(createJobAction("prdv:emails:resend"))
+program
+ .command("migrate-multi-compte")
+ .description("Migre les données vers les tables multi-compte")
+ .option("-q, --queued", "Run job asynchronously", false)
+ .action(createJobAction("migrate-multi-compte"))
+
export async function startCLI() {
await program.parseAsync(process.argv)
}
diff --git a/server/src/common/apis/FranceTravail.ts b/server/src/common/apis/FranceTravail.ts
index b7da918290..41ed0b1cb0 100644
--- a/server/src/common/apis/FranceTravail.ts
+++ b/server/src/common/apis/FranceTravail.ts
@@ -12,15 +12,6 @@ import { sentryCaptureException } from "../utils/sentryUtils"
import getApiClient from "./client"
-const FT_IO_API_ROME_V1_BASE_URL = "https://api.pole-emploi.io/partenaire/rome/v1"
-const FT_IO_API_OFFRES_BASE_URL = "https://api.pole-emploi.io/partenaire/offresdemploi/v2"
-const FT_AUTH_BASE_URL = "https://entreprise.pole-emploi.fr/connexion/oauth2"
-const FT_PORTAIL_BASE_URL = "https://portail-partenaire.pole-emploi.fr/partenaire"
-
-// paramètres exclurant les offres LBA des résultats de l'api PE
-const FT_LBA_PARTENAIRE = "LABONNEALTERNANCE"
-const FT_PARTENAIRE_MODE = "EXCLU"
-
const axiosClient = getApiClient({})
const ROME_ACESS = querystring.stringify({
@@ -60,7 +51,7 @@ const getFtAccessToken = async (access: "OFFRE" | "ROME", token): Promise => {
tokenOffreFT = await getFtAccessToken("OFFRE", tokenOffreFT)
+
try {
const extendedParams = {
...params,
- partenaires: FT_LBA_PARTENAIRE,
- modeSelectionPartenaires: FT_PARTENAIRE_MODE,
+ // paramètres exclurant les offres LBA des résultats de l'api PE
+ partenaires: "LABONNEALTERNANCE",
+ modeSelectionPartenaires: "EXCLU",
}
- const { data } = await axiosClient.get(`${FT_IO_API_OFFRES_BASE_URL}/offres/search`, {
+ const { data } = await axiosClient.get(`${config.franceTravailIO.baseUrl}/offresdemploi/v2/offres/search`, {
params: extendedParams,
headers: {
"Content-Type": "application/json",
@@ -118,7 +111,7 @@ export const searchForFtJobs = async (params: {
export const getFtJob = async (id: string) => {
tokenOffreFT = await getFtAccessToken("OFFRE", tokenOffreFT)
try {
- const result = await axiosClient.get(`${FT_IO_API_OFFRES_BASE_URL}/offres/${id}`, {
+ const result = await axiosClient.get(`${config.franceTravailIO.baseUrl}/offresdemploi/v2/offres/${id}`, {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
@@ -143,7 +136,7 @@ export const getFtReferentiels = async (referentiel: string) => {
try {
tokenOffreFT = await getFtAccessToken("OFFRE", tokenOffreFT)
- const data = await axiosClient.get(`${FT_IO_API_OFFRES_BASE_URL}/referentiel/${referentiel}`, {
+ const data = await axiosClient.get(`${config.franceTravailIO.baseUrl}/offresdemploi/v2/referentiel/${referentiel}`, {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
@@ -166,7 +159,7 @@ export const getRomeDetailsFromAPI = async (romeCode: string): Promise(`${FT_IO_API_ROME_V1_BASE_URL}/metier/${romeCode}`, {
+ const { data } = await axiosClient.get(`${config.franceTravailIO.baseUrl}/rome/v1/metier/${romeCode}`, {
headers: {
Authorization: `Bearer ${tokenRomeFT.access_token}`,
},
@@ -183,7 +176,7 @@ export const getAppellationDetailsFromAPI = async (appellationCode: string): Pro
tokenRomeFT = await getFtAccessToken("ROME", tokenRomeFT)
try {
- const { data } = await axiosClient.get(`${FT_IO_API_ROME_V1_BASE_URL}/appellation/${appellationCode}`, {
+ const { data } = await axiosClient.get(`${config.franceTravailIO.baseUrl}/rome/v1/appellation/${appellationCode}`, {
headers: {
Authorization: `Bearer ${tokenRomeFT.access_token}`,
},
@@ -210,7 +203,7 @@ export const sendCsvToFranceTravail = async (csvPath: string): Promise =>
form.append("periodeRef", "")
try {
- const { data } = await axiosClient.post(`${FT_PORTAIL_BASE_URL}/depotcurl`, form, {
+ const { data } = await axiosClient.post(config.franceTravailIO.depotUrl, form, {
headers: {
...form.getHeaders(),
},
diff --git a/server/src/common/model/constants/emails.ts b/server/src/common/model/constants/emails.ts
deleted file mode 100644
index 1fd4fb1890..0000000000
--- a/server/src/common/model/constants/emails.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { BrevoEventStatus } from "../../../services/brevo.service"
-
-const emailStatus = {
- request: "Envoyé",
- click: "Clické",
- deferred: "Différé",
- delivered: "Délivré",
- soft_bounce: "Rejecté (soft)",
- spam: "Spam",
- unique_opened: "Ouverture unique",
- [BrevoEventStatus.HARD_BOUNCE]: "Rejeté (hard)",
- unsubscribed: "Désinscrit",
- opened: "Ouvert",
- invalid_email: "Email invalide",
- blocked: "Bloqué",
- error: "Erreur",
-}
-
-/**
- * @description Returns email status.
- * @param {string} status - Status stored in database
- * @return {string}
- */
-const getEmailStatus = (status) => emailStatus[status] || "N/C"
-
-export { getEmailStatus }
diff --git a/server/src/common/model/index.ts b/server/src/common/model/index.ts
index 1f06eaf1c0..5bbe1e5a52 100644
--- a/server/src/common/model/index.ts
+++ b/server/src/common/model/index.ts
@@ -25,6 +25,10 @@ import InternalJobs from "./schema/internalJobs/internalJobs.schema"
import Job from "./schema/jobs/jobs.schema"
import LbaCompany from "./schema/lbaCompany/lbaCompany.schema"
import LbaCompanyLegacy from "./schema/lbaCompanylegacy/lbaCompanyLegacy.schema"
+import { Cfa } from "./schema/multiCompte/cfa.schema"
+import { Entreprise } from "./schema/multiCompte/entreprise.schema"
+import { RoleManagement } from "./schema/multiCompte/roleManagement.schema"
+import { User2 } from "./schema/multiCompte/user2.schema"
import Opco from "./schema/opco/opco.schema"
import Optout from "./schema/optout/optout.schema"
import Recruiter from "./schema/recruiter/recruiter.schema"
@@ -112,4 +116,8 @@ export {
User,
UserRecruteur,
eligibleTrainingsForAppointmentHistory,
+ User2,
+ Entreprise,
+ Cfa,
+ RoleManagement,
}
diff --git a/server/src/common/model/schema/appointments/appointment.schema.ts b/server/src/common/model/schema/appointments/appointment.schema.ts
index dee0cc0a52..bd12fed578 100644
--- a/server/src/common/model/schema/appointments/appointment.schema.ts
+++ b/server/src/common/model/schema/appointments/appointment.schema.ts
@@ -1,4 +1,5 @@
import { IAppointment } from "shared"
+import { AppointmentUserType } from "shared/constants/appointment"
import { model, Schema } from "../../../mongodb"
import { mongoosePagination, Pagination } from "../_shared/mongoose-paginate"
@@ -147,6 +148,12 @@ export const appointmentSchema = new Schema(
default: null,
description: "Adresse email CFA",
},
+ applicant_type: {
+ type: String,
+ enum: [...Object.values(AppointmentUserType), null],
+ default: null,
+ description: "Role du demandeur : parent ou etudiant",
+ },
},
{
versionKey: false,
diff --git a/server/src/common/model/schema/jobs/jobs.schema.ts b/server/src/common/model/schema/jobs/jobs.schema.ts
index 0adf3f0bea..05dda3665f 100644
--- a/server/src/common/model/schema/jobs/jobs.schema.ts
+++ b/server/src/common/model/schema/jobs/jobs.schema.ts
@@ -153,6 +153,10 @@ export const jobsSchema = new Schema(
type: Number,
description: "Nombre de vues sur une page de recherche",
},
+ managed_by: {
+ type: String,
+ description: "Id de l'utilisateur gérant l'offre",
+ },
},
{
versionKey: false,
diff --git a/server/src/common/model/schema/multiCompte/buildMongooseModel.ts b/server/src/common/model/schema/multiCompte/buildMongooseModel.ts
new file mode 100644
index 0000000000..bf28f9d21a
--- /dev/null
+++ b/server/src/common/model/schema/multiCompte/buildMongooseModel.ts
@@ -0,0 +1,11 @@
+import mongoose, { Schema } from "mongoose"
+
+import { mongoosePagination, Pagination } from "../_shared/mongoose-paginate"
+
+const { model } = mongoose
+
+export const buildMongooseModel = (schema: Schema, tableName: string) => {
+ schema.plugin(mongoosePagination)
+
+ return model>(tableName, schema)
+}
diff --git a/server/src/common/model/schema/multiCompte/cfa.schema.ts b/server/src/common/model/schema/multiCompte/cfa.schema.ts
new file mode 100644
index 0000000000..17a8dedf16
--- /dev/null
+++ b/server/src/common/model/schema/multiCompte/cfa.schema.ts
@@ -0,0 +1,46 @@
+import { ICFA } from "shared/models/cfa.model.js"
+
+import { Schema } from "../../../mongodb.js"
+
+import { buildMongooseModel } from "./buildMongooseModel.js"
+
+const cfaSchema = new Schema(
+ {
+ origin: {
+ type: String,
+ description: "Origine de la creation (ex: Campagne mail, lien web, etc...) pour suivi",
+ },
+ siret: {
+ type: String,
+ description: "Siret de l'établissement",
+ },
+ raison_sociale: {
+ type: String,
+ description: "Raison social de l'établissement",
+ },
+ enseigne: {
+ type: String,
+ default: null,
+ description: "Enseigne de l'établissement",
+ },
+ address_detail: {
+ type: Object,
+ description: "Detail de l'adresse de l'établissement",
+ },
+ address: {
+ type: String,
+ description: "Adresse de l'établissement",
+ },
+ geo_coordinates: {
+ type: String,
+ default: null,
+ description: "Latitude/Longitude de l'adresse de l'entreprise",
+ },
+ },
+ {
+ timestamps: true,
+ versionKey: false,
+ }
+)
+
+export const Cfa = buildMongooseModel(cfaSchema, "cfa")
diff --git a/server/src/common/model/schema/multiCompte/entreprise.schema.ts b/server/src/common/model/schema/multiCompte/entreprise.schema.ts
new file mode 100644
index 0000000000..4489c6ae6c
--- /dev/null
+++ b/server/src/common/model/schema/multiCompte/entreprise.schema.ts
@@ -0,0 +1,91 @@
+import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js"
+import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent } from "shared/models/entreprise.model.js"
+
+import { Schema } from "../../../mongodb.js"
+
+import { buildMongooseModel } from "./buildMongooseModel.js"
+
+const statusEventSchema = new Schema(
+ {
+ validation_type: {
+ type: String,
+ enum: Object.values(VALIDATION_UTILISATEUR),
+ description: "Indique si l'action est ordonnée par un utilisateur ou le serveur",
+ },
+ status: {
+ type: String,
+ enum: Object.values(EntrepriseStatus),
+ description: "Statut",
+ index: true,
+ },
+ reason: {
+ type: String,
+ description: "Raison du changement de statut",
+ },
+ granted_by: {
+ type: String,
+ default: null,
+ description: "Utilisateur à l'origine du changement",
+ },
+ date: {
+ type: Date,
+ default: () => new Date(),
+ description: "Date de l'évènement",
+ },
+ },
+ { _id: false }
+)
+
+const entrepriseSchema = new Schema(
+ {
+ status: {
+ type: [statusEventSchema],
+ description: "Evénements liés au cycle de vie",
+ },
+ origin: {
+ type: String,
+ description: "Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi",
+ },
+ siret: {
+ type: String,
+ description: "Siret de l'établissement",
+ },
+ raison_sociale: {
+ type: String,
+ description: "Raison social de l'établissement",
+ },
+ enseigne: {
+ type: String,
+ default: null,
+ description: "Enseigne de l'établissement",
+ },
+ idcc: {
+ type: String,
+ description: "Identifiant convention collective de l'entreprise",
+ },
+ address: {
+ type: String,
+ description: "Adresse de l'établissement",
+ },
+ address_detail: {
+ type: Object,
+ description: "Detail de l'adresse de l'établissement",
+ },
+ geo_coordinates: {
+ type: String,
+ default: null,
+ description: "Latitude/Longitude de l'adresse de l'entreprise",
+ },
+ opco: {
+ type: String,
+ default: null,
+ description: "Information sur l'opco de l'entreprise",
+ },
+ },
+ {
+ timestamps: true,
+ versionKey: false,
+ }
+)
+
+export const Entreprise = buildMongooseModel(entrepriseSchema, "entreprise")
diff --git a/server/src/common/model/schema/multiCompte/roleManagement.schema.ts b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts
new file mode 100644
index 0000000000..489d7c7905
--- /dev/null
+++ b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts
@@ -0,0 +1,72 @@
+import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js"
+import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model.js"
+
+import { ObjectId, Schema } from "../../../mongodb.js"
+
+import { buildMongooseModel } from "./buildMongooseModel.js"
+
+const roleManagementEventSchema = new Schema(
+ {
+ validation_type: {
+ type: String,
+ enum: Object.values(VALIDATION_UTILISATEUR),
+ description: "Indique si l'action est ordonnée par un utilisateur ou le serveur",
+ },
+ status: {
+ type: String,
+ enum: Object.values(AccessStatus),
+ description: "Statut de l'accès",
+ index: true,
+ },
+ reason: {
+ type: String,
+ description: "Raison du changement de statut",
+ },
+ granted_by: {
+ type: String,
+ default: null,
+ description: "Utilisateur à l'origine du changement",
+ },
+ date: {
+ type: Date,
+ default: () => new Date(),
+ description: "Date de l'évènement",
+ },
+ },
+ { _id: false }
+)
+
+const roleManagementSchema = new Schema(
+ {
+ origin: {
+ type: String,
+ description: "Origine de la creation",
+ },
+ status: {
+ type: [roleManagementEventSchema],
+ description: "Evénements liés au cycle de vie de l'accès",
+ },
+ authorized_id: {
+ type: String,
+ description: "ID de l'entité sur laquelle l'accès est exercé",
+ index: true,
+ },
+ authorized_type: {
+ type: String,
+ enum: Object.values(AccessEntityType),
+ description: "Type de l'entité sur laquelle l'accès est exercé",
+ index: true,
+ },
+ user_id: {
+ type: ObjectId,
+ description: "ID de l'utilisateur ayant accès",
+ index: true,
+ },
+ },
+ {
+ timestamps: true,
+ versionKey: false,
+ }
+)
+
+export const RoleManagement = buildMongooseModel(roleManagementSchema, "roleManagement")
diff --git a/server/src/common/model/schema/multiCompte/user2.schema.ts b/server/src/common/model/schema/multiCompte/user2.schema.ts
new file mode 100644
index 0000000000..b9caed06fb
--- /dev/null
+++ b/server/src/common/model/schema/multiCompte/user2.schema.ts
@@ -0,0 +1,83 @@
+import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js"
+import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model.js"
+
+import { Schema } from "../../../mongodb.js"
+
+import { buildMongooseModel } from "./buildMongooseModel.js"
+
+const userStatusEventSchema = new Schema(
+ {
+ validation_type: {
+ type: String,
+ enum: Object.values(VALIDATION_UTILISATEUR),
+ description: "Indique si l'action est ordonnée par un utilisateur ou le serveur",
+ },
+ status: {
+ type: String,
+ enum: Object.values(UserEventType),
+ description: "Statut de l'utilisateur",
+ index: true,
+ },
+ reason: {
+ type: String,
+ description: "Raison du changement de statut",
+ },
+ granted_by: {
+ type: String,
+ default: null,
+ description: "Utilisateur à l'origine du changement",
+ },
+ date: {
+ type: Date,
+ default: () => new Date(),
+ description: "Date de l'évènement",
+ },
+ },
+ { _id: false }
+)
+
+const User2Schema = new Schema(
+ {
+ origin: {
+ type: String,
+ description: "Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi",
+ },
+ status: {
+ type: [userStatusEventSchema],
+ description: "Evénements liés au cycle de vie de l'utilisateur",
+ },
+ first_name: {
+ type: String,
+ default: null,
+ description: "Le prénom",
+ },
+ last_name: {
+ type: String,
+ default: null,
+ description: "Le nom",
+ },
+ email: {
+ type: String,
+ default: null,
+ description: "L'email",
+ index: true,
+ },
+ phone: {
+ type: String,
+ default: null,
+ description: "Le numéro de téléphone",
+ },
+ last_action_date: {
+ type: Date,
+ default: null,
+ description: "Date de dernière connexion",
+ index: true,
+ },
+ },
+ {
+ timestamps: true,
+ versionKey: false,
+ }
+)
+
+export const User2 = buildMongooseModel(User2Schema, "userswithaccount")
diff --git a/server/src/common/model/schema/recruiter/recruiter.schema.ts b/server/src/common/model/schema/recruiter/recruiter.schema.ts
index 341a579a19..1a7dfee3c2 100644
--- a/server/src/common/model/schema/recruiter/recruiter.schema.ts
+++ b/server/src/common/model/schema/recruiter/recruiter.schema.ts
@@ -29,6 +29,11 @@ const personalInfosRecruiterSchema = new Schema({
description: "Email du contact",
require: true,
},
+ managed_by: {
+ type: String,
+ default: null,
+ description: "Id de l'utilisateur gestionnaire",
+ },
})
export const nonPersonalInfosRecruiterSchema = new Schema({
diff --git a/server/src/common/model/schema/user/user.schema.ts b/server/src/common/model/schema/user/user.schema.ts
index eb4890fb50..89a6f5f5a5 100644
--- a/server/src/common/model/schema/user/user.schema.ts
+++ b/server/src/common/model/schema/user/user.schema.ts
@@ -33,7 +33,7 @@ export const userSchema = new Schema(
role: {
type: String,
default: null,
- description: "candidat | cfa | administrator",
+ description: "candidat | administrator",
},
last_action_date: {
type: Date,
diff --git a/server/src/common/mongodb.ts b/server/src/common/mongodb.ts
index f38b1c69dd..522d1edd77 100644
--- a/server/src/common/mongodb.ts
+++ b/server/src/common/mongodb.ts
@@ -1,9 +1,14 @@
+import mongodb from "mongodb"
+import type { ObjectId as ObjectIdType } from "mongodb"
import mongoose from "mongoose"
import config from "../config"
import { logger } from "./logger"
+const { ObjectId } = mongodb
+export { ObjectId }
+export type { ObjectIdType }
export const mongooseInstance = mongoose
export const { model, Schema } = mongoose
// @ts-expect-error
diff --git a/server/src/common/utils/apiGeoAdresse.ts b/server/src/common/utils/apiGeoAdresse.ts
index 27f86fad08..21ea32890f 100644
--- a/server/src/common/utils/apiGeoAdresse.ts
+++ b/server/src/common/utils/apiGeoAdresse.ts
@@ -67,7 +67,7 @@ class ApiGeoAdresse {
response = await getHttpClient().get(query)
if (response?.data?.status === 429) {
- console.log("429 ", new Date(), query)
+ console.warn("429 ", new Date(), query)
trys++
await new Promise((resolve) => setTimeout(resolve, 1000))
} else {
diff --git a/server/src/common/utils/asyncUtils.ts b/server/src/common/utils/asyncUtils.ts
index 502389b0b6..157d076578 100644
--- a/server/src/common/utils/asyncUtils.ts
+++ b/server/src/common/utils/asyncUtils.ts
@@ -4,7 +4,14 @@ export const asyncForEach = async (array: T[], callback: (item: T, index: num
}
}
-export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
+export const asyncForEachGrouped = async (array: T[], groupSize: number, callback: (item: T, index: number) => Promise) => {
+ for (let index = 0; index < array.length; index += groupSize) {
+ const group = array.slice(index, index + groupSize)
+ await Promise.all(group.map((item, itemIndex) => callback(item, index + itemIndex)))
+ }
+}
+
+export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export function timeout(promise, millis) {
let timeout: NodeJS.Timeout | null = null
diff --git a/server/src/config.ts b/server/src/config.ts
index 7cf26b7809..19118e5ddc 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -77,6 +77,11 @@ const config = {
password: env.get("LBA_FRANCE_TRAVAIL_DEPOT_OFFRES_PASSWORD").required().asString(),
nomFlux: "LABONATA",
},
+ franceTravailIO: {
+ baseUrl: "https://api.francetravail.io/partenaire",
+ authUrl: "https://entreprise.francetravail.fr/connexion/oauth2/access_token",
+ depotUrl: "https://portail-partenaire.pole-emploi.fr/partenaire/depotcurl",
+ },
bal: {
baseUrl: env.get("LBA_BAL_ENV_URL").required().asString(),
apiKey: env.get("LBA_BAL_API_KEY").required().asString(),
@@ -111,6 +116,10 @@ const config = {
tco: {
baseUrl: "https://tables-correspondances.apprentissage.beta.gouv.fr",
},
+ apiApprentissage: {
+ baseUrl: "https://api.apprentissage.beta.gouv.fr/api",
+ apiKey: env.get("LBA_API_APPRENTISSAGE_KEY").required().asString(),
+ },
parcoursupPeriods: {
start: {
startMonth: 0, // January = 0
diff --git a/server/src/http/controllers/appointmentRequest.controller.ts b/server/src/http/controllers/appointmentRequest.controller.ts
index b47af4dcb5..3cead8a9cc 100644
--- a/server/src/http/controllers/appointmentRequest.controller.ts
+++ b/server/src/http/controllers/appointmentRequest.controller.ts
@@ -307,7 +307,7 @@ export default (server: Server) => {
getParameterByCleMinistereEducatif({
cleMinistereEducatif: cle_ministere_educatif,
}),
- users.getUserById(appointment.applicant_id),
+ users.getUserById(appointment.applicant_id.toString()),
])
if (!user) throw Boom.notFound()
diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts
index 253928de60..1b321264f0 100644
--- a/server/src/http/controllers/etablissementRecruteur.controller.ts
+++ b/server/src/http/controllers/etablissementRecruteur.controller.ts
@@ -1,18 +1,17 @@
import Boom from "boom"
-import { IUserRecruteur, toPublicUser, zRoutes } from "shared"
+import { assertUnreachable, toPublicUser, zRoutes } from "shared"
import { BusinessErrorCodes } from "shared/constants/errorCodes"
-import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur"
+import { RECRUITER_STATUS } from "shared/constants/recruteur"
+import { AccessStatus } from "shared/models/roleManagement.model"
+import { UserEventType } from "shared/models/user2.model"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
-import { Recruiter, UserRecruteur } from "@/common/model"
+import { Cfa, Recruiter } from "@/common/model"
import { startSession } from "@/common/utils/session.service"
import config from "@/config"
+import { user2ToUserForToken } from "@/security/accessTokenService"
import { getUserFromRequest } from "@/security/authenticationService"
import { generateDepotSimplifieToken } from "@/services/appLinks.service"
-
-import { getAllDomainsFromEmailList, getEmailDomain, isEmailFromPrivateCompany, isUserMailExistInReferentiel } from "../../common/utils/mailUtils"
-import { notifyToSlack } from "../../common/utils/slackUtils"
-import { getNearEtablissementsFromRomes } from "../../services/catalogue.service"
-import { CFA, ENTREPRISE } from "../../services/constant.service"
import {
entrepriseOnboardingWorkflow,
etablissementUnsubscribeDemandeDelegation,
@@ -21,18 +20,24 @@ import {
getOrganismeDeFormationDataFromSiret,
sendUserConfirmationEmail,
validateCreationEntrepriseFromCfa,
- validateEtablissementEmail,
-} from "../../services/etablissement.service"
+} from "@/services/etablissement.service"
+import { getMainRoleManagement, getPublicUserRecruteurPropsOrError } from "@/services/roleManagement.service"
+import { getUser2ByEmail, validateUser2Email } from "@/services/user2.service"
import {
autoValidateUser,
- createUser,
- getUser,
- getUserStatus,
+ createOrganizationUser,
+ getUserRecruteurByEmail,
+ isUserEmailChecked,
sendWelcomeEmailToUserRecruteur,
setUserHasToBeManuallyValidated,
updateLastConnectionDate,
- updateUser,
-} from "../../services/userRecruteur.service"
+ updateUser2Fields,
+} from "@/services/userRecruteur.service"
+
+import { getAllDomainsFromEmailList, getEmailDomain, isEmailFromPrivateCompany, isUserMailExistInReferentiel } from "../../common/utils/mailUtils"
+import { notifyToSlack } from "../../common/utils/slackUtils"
+import { getNearEtablissementsFromRomes } from "../../services/catalogue.service"
+import { CFA, ENTREPRISE } from "../../services/constant.service"
import { Server } from "../server"
export default (server: Server) => {
@@ -72,7 +77,7 @@ export default (server: Server) => {
throw Boom.badRequest(cfaVerification.message)
}
- const result = await getEntrepriseDataFromSiret({ siret, cfa_delegated_siret })
+ const result = await getEntrepriseDataFromSiret({ siret, type: cfa_delegated_siret ? CFA : ENTREPRISE })
if ("error" in result) {
throw Boom.badRequest(result.message, result)
@@ -125,18 +130,18 @@ export default (server: Server) => {
* Retourne les entreprises gérées par un CFA
*/
server.get(
- "/etablissement/cfa/:userRecruteurId/entreprises",
+ "/etablissement/cfa/:cfaId/entreprises",
{
- schema: zRoutes.get["/etablissement/cfa/:userRecruteurId/entreprises"],
- onRequest: [server.auth(zRoutes.get["/etablissement/cfa/:userRecruteurId/entreprises"])],
+ schema: zRoutes.get["/etablissement/cfa/:cfaId/entreprises"],
+ onRequest: [server.auth(zRoutes.get["/etablissement/cfa/:cfaId/entreprises"])],
},
async (req, res) => {
- const { userRecruteurId } = req.params
- const cfa = await UserRecruteur.findOne({ _id: userRecruteurId }).lean()
+ const { cfaId } = req.params
+ const cfa = await Cfa.findOne({ _id: cfaId }).lean()
if (!cfa) {
- throw Boom.notFound(`Aucun CFA ayant pour id ${userRecruteurId.toString()}`)
+ throw Boom.notFound(`Aucun CFA ayant pour id ${cfaId.toString()}`)
}
- const cfa_delegated_siret = cfa.establishment_siret
+ const cfa_delegated_siret = cfa.siret
if (!cfa_delegated_siret) {
throw Boom.internal(`inattendu : le cfa n'a pas de champ cfa_delegated_siret`)
}
@@ -154,7 +159,8 @@ export default (server: Server) => {
schema: zRoutes.post["/etablissement/creation"],
},
async (req, res) => {
- switch (req.body.type) {
+ const { type } = req.body
+ switch (type) {
case ENTREPRISE: {
const siret = req.body.establishment_siret
const cfa_delegated_siret = req.body.cfa_delegated_siret ?? undefined
@@ -163,14 +169,15 @@ export default (server: Server) => {
if (result.errorCode === BusinessErrorCodes.ALREADY_EXISTS) throw Boom.forbidden(result.message, result)
else throw Boom.badRequest(result.message, result)
}
- const token = generateDepotSimplifieToken(result.user)
- return res.status(200).send({ ...result, token })
+ const token = generateDepotSimplifieToken(user2ToUserForToken(result.user), result.formulaire.establishment_id)
+ return res.status(200).send({ formulaire: result.formulaire, user: result.user, token, validated: result.validated })
}
case CFA: {
const { email, establishment_siret } = req.body
+ const origin = req.body.origin ?? "formulaire public de création"
const formatedEmail = email.toLocaleLowerCase()
// check if user already exist
- const userRecruteurOpt = await getUser({ email: formatedEmail })
+ const userRecruteurOpt = await getUserRecruteurByEmail(formatedEmail)
if (userRecruteurOpt) {
throw Boom.forbidden("L'adresse mail est déjà associée à un compte La bonne alternance.")
}
@@ -179,44 +186,45 @@ export default (server: Server) => {
const { contacts } = siretInfos
// Creation de l'utilisateur en base de données
- let newCfa: IUserRecruteur = await createUser({ ...req.body, ...siretInfos, is_email_checked: false })
+ const creationResult = await createOrganizationUser({ ...req.body, ...siretInfos, is_email_checked: false, origin })
+ const userCfa = creationResult.user
const slackNotification = {
subject: "RECRUTEUR",
- message: `Nouvel OF en attente de validation - ${config.publicUrl}/espace-pro/administration/users/${newCfa._id}`,
+ message: `Nouvel OF en attente de validation - ${config.publicUrl}/espace-pro/administration/users/${userCfa._id}`,
}
if (!contacts.length) {
// Validation manuelle de l'utilisateur à effectuer pas un administrateur
- newCfa = await setUserHasToBeManuallyValidated(newCfa._id)
+ await setUserHasToBeManuallyValidated(creationResult, origin, "pas d'email de contact")
await notifyToSlack(slackNotification)
- return res.status(200).send({ user: newCfa })
+ return res.status(200).send({ user: userCfa, validated: false })
}
if (isUserMailExistInReferentiel(contacts, email)) {
// Validation automatique de l'utilisateur
- newCfa = await autoValidateUser(newCfa._id)
- await sendUserConfirmationEmail(newCfa)
+ await autoValidateUser(creationResult, origin, "l'email correspond à un contact")
+ await sendUserConfirmationEmail(userCfa)
// Keep the same structure as ENTREPRISE
- return res.status(200).send({ user: newCfa })
+ return res.status(200).send({ user: userCfa, validated: true })
}
if (isEmailFromPrivateCompany(formatedEmail)) {
const domains = getAllDomainsFromEmailList(contacts.map(({ email }) => email))
const userEmailDomain = getEmailDomain(formatedEmail)
if (userEmailDomain && domains.includes(userEmailDomain)) {
// Validation automatique de l'utilisateur
- newCfa = await autoValidateUser(newCfa._id)
- await sendUserConfirmationEmail(newCfa)
+ await autoValidateUser(creationResult, origin, "le nom de domaine de l'email correspond à celui d'un contact")
+ await sendUserConfirmationEmail(userCfa)
// Keep the same structure as ENTREPRISE
- return res.status(200).send({ user: newCfa })
+ return res.status(200).send({ user: userCfa, validated: true })
}
}
// Validation manuelle de l'utilisateur à effectuer pas un administrateur
- newCfa = await setUserHasToBeManuallyValidated(newCfa._id)
+ await setUserHasToBeManuallyValidated(creationResult, origin, "pas de validation automatique possible")
await notifyToSlack(slackNotification)
// Keep the same structure as ENTREPRISE
- return res.status(200).send({ user: newCfa })
+ return res.status(200).send({ user: userCfa, validated: false })
}
default: {
- throw Boom.badRequest("unsupported type")
+ assertUnreachable(type)
}
}
}
@@ -250,11 +258,10 @@ export default (server: Server) => {
},
async (req, res) => {
const { _id, ...rest } = req.body
- const exists = await UserRecruteur.findOne({ email: req.body.email?.toLocaleLowerCase(), _id: { $ne: _id } })
- if (exists) {
+ const result = await updateUser2Fields(req.params.id, rest)
+ if ("error" in result) {
throw Boom.badRequest("L'adresse mail est déjà associée à un compte La bonne alternance.")
}
- await updateUser({ _id: req.params.id }, rest)
return res.status(200).send({ ok: true })
}
)
@@ -266,30 +273,28 @@ export default (server: Server) => {
onRequest: [server.auth(zRoutes.post["/etablissement/validation"])],
},
async (req, res) => {
- const user = getUserFromRequest(req, zRoutes.post["/etablissement/validation"]).value
-
- // Validate email
- const userRecruteur = await validateEtablissementEmail(user.identity.email.toLocaleLowerCase())
+ const userFromRequest = getUserFromRequest(req, zRoutes.post["/etablissement/validation"]).value
+ const email = userFromRequest.identity.email.toLocaleLowerCase()
- if (!userRecruteur) {
+ const user = await getUser2ByEmail(email)
+ if (!user) {
throw Boom.badRequest("La validation de l'adresse mail a échoué. Merci de contacter le support La bonne alternance.")
}
-
- const isUserAwaiting = getUserStatus(userRecruteur.status) === ETAT_UTILISATEUR.ATTENTE
-
- if (!isUserAwaiting) {
- await sendWelcomeEmailToUserRecruteur(userRecruteur)
+ const userStatus = getLastStatusEvent(user.status)?.status
+ if (userStatus === UserEventType.DESACTIVE) {
+ throw Boom.forbidden("Votre compte est désactivé. Merci de contacter le support La bonne alternance.")
}
-
- const connectedUser = await updateLastConnectionDate(userRecruteur.email)
-
- if (!connectedUser) {
- throw Boom.forbidden()
+ if (!isUserEmailChecked(user)) {
+ await validateUser2Email(user._id.toString())
+ }
+ const mainRole = await getMainRoleManagement(user._id, true)
+ if (getLastStatusEvent(mainRole?.status)?.status === AccessStatus.GRANTED) {
+ await sendWelcomeEmailToUserRecruteur(user)
}
- await startSession(userRecruteur.email, res)
-
- return res.status(200).send(toPublicUser(connectedUser))
+ await updateLastConnectionDate(email)
+ await startSession(email, res)
+ return res.status(200).send(toPublicUser(user, await getPublicUserRecruteurPropsOrError(user._id, true)))
}
)
}
diff --git a/server/src/http/controllers/formations.controller.ts b/server/src/http/controllers/formations.controller.ts
index 38e918508e..4fea3d1158 100644
--- a/server/src/http/controllers/formations.controller.ts
+++ b/server/src/http/controllers/formations.controller.ts
@@ -92,20 +92,8 @@ export default (server: Server) => {
async (req, res) => {
const { id } = req.params
const { caller } = req.query
- const result = await getFormationQuery({
- id,
- caller,
- })
-
- if ("error" in result) {
- if (result.error === "wrong_parameters") {
- res.status(400)
- } else if (result.error === "not_found") {
- res.status(404)
- } else {
- res.status(500)
- }
- } else {
+ try {
+ const result = await getFormationQuery({ id })
if (caller) {
trackApiCall({
caller,
@@ -115,9 +103,13 @@ export default (server: Server) => {
response: "OK",
})
}
+ return res.send(result)
+ } catch (err) {
+ if (caller) {
+ trackApiCall({ caller, api_path: "formationV1/formation", response: "Error" })
+ }
+ throw err
}
-
- return res.send(result)
}
)
}
diff --git a/server/src/http/controllers/formations.controller.v2.ts b/server/src/http/controllers/formations.controller.v2.ts
index 998618554f..b7ffd891dd 100644
--- a/server/src/http/controllers/formations.controller.v2.ts
+++ b/server/src/http/controllers/formations.controller.v2.ts
@@ -92,20 +92,8 @@ export default (server: Server) => {
async (req, res) => {
const { id } = req.params
const { caller } = req.query
- const result = await getFormationQuery({
- id,
- caller,
- })
-
- if ("error" in result) {
- if (result.error === "wrong_parameters") {
- res.status(400)
- } else if (result.error === "not_found") {
- res.status(404)
- } else {
- res.status(500)
- }
- } else {
+ try {
+ const result = await getFormationQuery({ id })
if (caller) {
trackApiCall({
caller,
@@ -115,9 +103,13 @@ export default (server: Server) => {
response: "OK",
})
}
+ return res.send(result)
+ } catch (err) {
+ if (caller) {
+ trackApiCall({ caller, api_path: "formationV1/formation", response: "Error" })
+ }
+ throw err
}
-
- return res.send(result)
}
)
server.get(
diff --git a/server/src/http/controllers/formulaire.controller.ts b/server/src/http/controllers/formulaire.controller.ts
index e459f6fe15..d53438aef3 100644
--- a/server/src/http/controllers/formulaire.controller.ts
+++ b/server/src/http/controllers/formulaire.controller.ts
@@ -1,8 +1,10 @@
import Boom from "boom"
import { zRoutes } from "shared/index"
-import { UserRecruteur } from "@/common/model"
+import { getUserFromRequest } from "@/security/authenticationService"
import { generateOffreToken } from "@/services/appLinks.service"
+import { getUser2ByEmail } from "@/services/user2.service"
+import { getUserRecruteurById } from "@/services/userRecruteur.service"
import { getApplicationsByJobId } from "../../services/application.service"
import { entrepriseOnboardingWorkflow } from "../../services/etablissement.service"
@@ -21,7 +23,6 @@ import {
provideOffre,
updateFormulaire,
} from "../../services/formulaire.service"
-import { getUser } from "../../services/userRecruteur.service"
import { Server } from "../server"
export default (server: Server) => {
@@ -105,7 +106,7 @@ export default (server: Server) => {
async (req, res) => {
const { userId: userRecruteurId } = req.params
const { establishment_siret, email, last_name, first_name, phone, opco, idcc } = req.body
- const userRecruteurOpt = await getUser({ _id: userRecruteurId })
+ const userRecruteurOpt = await getUserRecruteurById(userRecruteurId)
if (!userRecruteurOpt) {
throw Boom.badRequest("Nous n'avons pas trouvé votre compte utilisateur")
}
@@ -122,6 +123,7 @@ export default (server: Server) => {
origin: userRecruteurOpt.scope,
opco,
idcc,
+ managedBy: userRecruteurOpt._id.toString(),
})
if ("error" in response) {
const { message } = response
@@ -202,6 +204,7 @@ export default (server: Server) => {
},
async (req, res) => {
const { establishment_id } = req.params
+ const user = getUserFromRequest(req, zRoutes.post["/formulaire/:establishment_id/offre"]).value
const {
is_disabled_elligible,
job_type,
@@ -231,13 +234,15 @@ export default (server: Server) => {
rome_code,
rome_label,
},
+ user,
establishment_id,
})
const job = updatedFormulaire.jobs.at(0)
if (!job) {
throw new Error("unexpected")
}
- return res.status(200).send({ recruiter: updatedFormulaire })
+ const token = generateOffreToken(user, job)
+ return res.status(200).send({ recruiter: updatedFormulaire, token })
}
)
@@ -253,6 +258,12 @@ export default (server: Server) => {
},
async (req, res) => {
const { establishment_id } = req.params
+ const tokenUser = getUserFromRequest(req, zRoutes.post["/formulaire/:establishment_id/offre/by-token"]).value
+ const { email } = tokenUser.identity
+ const user = await getUser2ByEmail(email)
+ if (!user) {
+ throw Boom.internal(`inattendu : impossible de récupérer l'utilisateur de type token ayant pour email=${email}`)
+ }
const {
is_disabled_elligible,
job_type,
@@ -267,10 +278,6 @@ export default (server: Server) => {
rome_code,
rome_label,
} = req.body
- const userRecruteur = await UserRecruteur.findOne({ establishment_id }).lean()
- if (!userRecruteur) {
- throw Boom.notFound()
- }
const updatedFormulaire = await createJob({
job: {
is_disabled_elligible,
@@ -287,12 +294,13 @@ export default (server: Server) => {
rome_label,
},
establishment_id,
+ user,
})
const job = updatedFormulaire.jobs.at(0)
if (!job) {
throw new Error("unexpected")
}
- const token = generateOffreToken(userRecruteur, job)
+ const token = generateOffreToken(user, job)
return res.status(200).send({ recruiter: updatedFormulaire, token })
}
)
diff --git a/server/src/http/controllers/jobs.controller.ts b/server/src/http/controllers/jobs.controller.ts
index 33b11011a2..80af35107e 100644
--- a/server/src/http/controllers/jobs.controller.ts
+++ b/server/src/http/controllers/jobs.controller.ts
@@ -3,6 +3,7 @@ import { IJob, JOB_STATUS, zRoutes } from "shared"
import { getUserFromRequest } from "@/security/authenticationService"
import { Appellation } from "@/services/rome.service.types"
+import { getUser2ByEmail } from "@/services/user2.service"
import { Recruiter } from "../../common/model/index"
import { getNearEtablissementsFromRomes } from "../../services/catalogue.service"
@@ -25,7 +26,7 @@ import {
import { getFtJobFromId } from "../../services/ftjob.service"
import { getJobsQuery } from "../../services/jobOpportunity.service"
import { getCompanyFromSiret } from "../../services/lbacompany.service"
-import { addOffreDetailView, getLbaJobById, incrementLbaJobsViewCount } from "../../services/lbajob.service"
+import { addOffreDetailView, getLbaJobById } from "../../services/lbajob.service"
import { getFicheMetierRomeV3FromDB } from "../../services/rome.service"
import { Server } from "../server"
@@ -133,6 +134,10 @@ export default (server: Server) => {
if (!establishmentExists) {
return res.status(400).send({ error: true, message: "Establishment does not exist" })
}
+ const user = await getUser2ByEmail(establishmentExists.email)
+ if (!user) {
+ return res.status(400).send({ error: true, message: "User does not exist" })
+ }
const romeDetails = await getFicheMetierRomeV3FromDB({
query: {
@@ -165,6 +170,7 @@ export default (server: Server) => {
job_rythm: body.job_rythm,
custom_address: body.custom_address,
custom_geo_coordinates: body.custom_geo_coordinates,
+ managed_by: user._id,
}
const updatedRecruiter = await createOffre(establishmentId, job)
@@ -347,12 +353,6 @@ export default (server: Server) => {
if ("error" in result) {
return res.status(500).send(result)
}
-
- if ("matchas" in result && result.matchas) {
- const { matchas } = result
- await incrementLbaJobsViewCount(matchas)
- }
-
return res.status(200).send(result)
}
)
@@ -370,12 +370,6 @@ export default (server: Server) => {
if ("error" in result) {
return res.status(500).send(result)
}
-
- if ("matchas" in result && result.matchas) {
- const { matchas } = result
- await incrementLbaJobsViewCount(matchas)
- }
-
return res.status(200).send(result)
}
)
@@ -471,6 +465,7 @@ export default (server: Server) => {
async (req, res) => {
const { id } = req.params
const { caller } = req.query
+
const result = await getFtJobFromId({
id,
caller,
diff --git a/server/src/http/controllers/jobs.controller.v2.ts b/server/src/http/controllers/jobs.controller.v2.ts
index 1737ca10b0..150fdb35d7 100644
--- a/server/src/http/controllers/jobs.controller.v2.ts
+++ b/server/src/http/controllers/jobs.controller.v2.ts
@@ -1,5 +1,5 @@
import Boom from "boom"
-import { IJob, ILbaItemLbaJob, ILbaItemFtJob, JOB_STATUS, assertUnreachable, zRoutes } from "shared"
+import { IJob, ILbaItemFtJob, ILbaItemLbaJob, JOB_STATUS, assertUnreachable, zRoutes } from "shared"
import { LBA_ITEM_TYPE } from "shared/constants/lbaitem"
import { getUserFromRequest } from "@/security/authenticationService"
@@ -26,7 +26,7 @@ import {
import { getFtJobFromIdV2 } from "../../services/ftjob.service"
import { getJobsQuery } from "../../services/jobOpportunity.service"
import { getCompanyFromSiret } from "../../services/lbacompany.service"
-import { addOffreDetailView, getLbaJobByIdV2, incrementLbaJobsViewCount } from "../../services/lbajob.service"
+import { addOffreDetailView, getLbaJobByIdV2 } from "../../services/lbajob.service"
import { getFicheMetierRomeV3FromDB } from "../../services/rome.service"
import { Server } from "../server"
@@ -348,12 +348,6 @@ export default (server: Server) => {
if ("error" in result) {
return res.status(500).send(result)
}
-
- if ("matchas" in result && result.matchas) {
- const { matchas } = result
- await incrementLbaJobsViewCount(matchas)
- }
-
return res.status(200).send(result)
}
)
@@ -372,12 +366,6 @@ export default (server: Server) => {
if ("error" in result) {
return res.status(500).send(result)
}
-
- if ("matchas" in result && result.matchas) {
- const { matchas } = result
- await incrementLbaJobsViewCount(matchas)
- }
-
return res.status(200).send(result)
}
)
diff --git a/server/src/http/controllers/login.controller.ts b/server/src/http/controllers/login.controller.ts
index b0eedf7318..85535caab1 100644
--- a/server/src/http/controllers/login.controller.ts
+++ b/server/src/http/controllers/login.controller.ts
@@ -2,16 +2,20 @@ import Boom from "boom"
import { removeUrlsFromText } from "shared/helpers/common"
import { toPublicUser, zRoutes } from "shared/index"
+import { User2 } from "@/common/model"
import { getStaticFilePath } from "@/common/utils/getStaticFilePath"
+import { user2ToUserForToken } from "@/security/accessTokenService"
import { getUserFromRequest } from "@/security/authenticationService"
import { createAuthMagicLink } from "@/services/appLinks.service"
+import { getComputedUserAccess, getGrantedRoles, getPublicUserRecruteurPropsOrError } from "@/services/roleManagement.service"
+import { getUser2ByEmail } from "@/services/user2.service"
import { startSession, stopSession } from "../../common/utils/session.service"
import config from "../../config"
import { sendUserConfirmationEmail } from "../../services/etablissement.service"
import { controlUserState } from "../../services/login.service"
import mailer, { sanitizeForEmail } from "../../services/mailer.service"
-import { getUser, updateLastConnectionDate } from "../../services/userRecruteur.service"
+import { isUserEmailChecked, updateLastConnectionDate } from "../../services/userRecruteur.service"
import { Server } from "../server"
export default (server: Server) => {
@@ -23,11 +27,11 @@ export default (server: Server) => {
},
async (req, res) => {
const { userId } = req.params
- const user = await getUser({ _id: userId })
+ const user = await User2.findOne({ _id: userId }).lean()
if (!user) {
return res.status(400).send({ error: true, reason: "UNKNOWN" })
}
- const { is_email_checked } = user
+ const is_email_checked = isUserEmailChecked(user)
if (is_email_checked) {
return res.status(400).send({ error: true, reason: "VERIFIED" })
}
@@ -44,18 +48,14 @@ export default (server: Server) => {
async (req, res) => {
const { email } = req.body
const formatedEmail = email.toLowerCase()
- const user = await getUser({ email: formatedEmail })
+ const user = await User2.findOne({ email: formatedEmail }).lean()
if (!user) {
return res.status(400).send({ error: true, reason: "UNKNOWN" })
}
- const { email: userEmail, first_name, last_name, is_email_checked } = user
-
- const userState = controlUserState(user.status)
- if (userState?.error) {
- return res.status(400).send(userState)
- }
+ const is_email_checked = isUserEmailChecked(user)
+ const { email: userEmail, first_name, last_name } = user
if (!is_email_checked) {
await sendUserConfirmationEmail(user)
@@ -65,6 +65,11 @@ export default (server: Server) => {
})
}
+ const userState = await controlUserState(user)
+ if (userState?.error) {
+ return res.status(400).send(userState)
+ }
+
await mailer.sendEmail({
to: userEmail,
subject: "Lien de connexion",
@@ -75,7 +80,7 @@ export default (server: Server) => {
},
last_name: sanitizeForEmail(removeUrlsFromText(last_name)),
first_name: sanitizeForEmail(removeUrlsFromText(first_name)),
- connexion_url: createAuthMagicLink(user),
+ connexion_url: createAuthMagicLink(user2ToUserForToken(user)),
},
})
return res.status(200).send({})
@@ -89,31 +94,26 @@ export default (server: Server) => {
onRequest: [server.auth(zRoutes.post["/login/verification"])],
},
async (req, res) => {
- const user = getUserFromRequest(req, zRoutes.post["/login/verification"]).value
- const { email } = user.identity
+ const userFromRequest = getUserFromRequest(req, zRoutes.post["/login/verification"]).value
+ const { email } = userFromRequest.identity
const formatedEmail = email.toLowerCase()
- const userData = await getUser({ email: formatedEmail })
+ const user = await getUser2ByEmail(formatedEmail)
- if (!userData) {
+ if (!user) {
throw Boom.unauthorized()
}
- const userState = controlUserState(userData?.status)
+ const userState = await controlUserState(user)
if (userState?.error) {
throw Boom.forbidden()
}
- const connectedUser = await updateLastConnectionDate(formatedEmail)
-
- if (!connectedUser) {
- throw Boom.forbidden()
- }
-
+ await updateLastConnectionDate(formatedEmail)
await startSession(email, res)
- return res.status(200).send(toPublicUser(connectedUser))
+ return res.status(200).send(toPublicUser(user, await getPublicUserRecruteurPropsOrError(user._id)))
}
)
@@ -130,8 +130,25 @@ export default (server: Server) => {
if (!request.user) {
throw Boom.forbidden()
}
- const user = getUserFromRequest(request, zRoutes.get["/auth/session"]).value
- return response.status(200).send(toPublicUser(user))
+ const userFromRequest = getUserFromRequest(request, zRoutes.get["/auth/session"]).value
+ return response.status(200).send(toPublicUser(userFromRequest, await getPublicUserRecruteurPropsOrError(userFromRequest._id)))
+ }
+ )
+
+ server.get(
+ "/auth/access",
+ {
+ schema: zRoutes.get["/auth/access"],
+ onRequest: [server.auth(zRoutes.get["/auth/access"])],
+ },
+ async (request, response) => {
+ if (!request.user) {
+ throw Boom.forbidden()
+ }
+ const userFromRequest = getUserFromRequest(request, zRoutes.get["/auth/access"]).value
+ const userId = userFromRequest._id.toString()
+ const userAccess = getComputedUserAccess(userId, await getGrantedRoles(userId))
+ return response.status(200).send(userAccess)
}
)
diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts
index c2d532b210..84991261ca 100644
--- a/server/src/http/controllers/user.controller.ts
+++ b/server/src/http/controllers/user.controller.ts
@@ -1,28 +1,38 @@
import Boom from "boom"
-import { CFA, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur"
-import { IJob, getUserStatus, zRoutes } from "shared/index"
+import { CFA, VALIDATION_UTILISATEUR } from "shared/constants/recruteur"
+import { IJob, IRecruiter, getUserStatus, zRoutes } from "shared/index"
+import { ICFA } from "shared/models/cfa.model"
+import { IEntreprise } from "shared/models/entreprise.model"
+import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
import { stopSession } from "@/common/utils/session.service"
import { getUserFromRequest } from "@/security/authenticationService"
+import { modifyPermissionToUser, roleToUserType } from "@/services/roleManagement.service"
+import { validateUser2Email } from "@/services/user2.service"
-import { Recruiter, UserRecruteur } from "../../common/model/index"
+import { Cfa, Entreprise, RoleManagement, User2 } from "../../common/model/index"
import { getStaticFilePath } from "../../common/utils/getStaticFilePath"
import config from "../../config"
import { ENTREPRISE, RECRUITER_STATUS } from "../../services/constant.service"
-import { activateEntrepriseRecruiterForTheFirstTime, deleteFormulaire, getFormulaire, reactivateRecruiter } from "../../services/formulaire.service"
+import {
+ activateEntrepriseRecruiterForTheFirstTime,
+ deleteFormulaire,
+ getFormulaireFromUserId,
+ getFormulaireFromUserIdOrError,
+ reactivateRecruiter,
+} from "../../services/formulaire.service"
import mailer, { sanitizeForEmail } from "../../services/mailer.service"
-import { getUserAndRecruitersDataForOpcoUser, getValidatorIdentityFromStatus } from "../../services/user.service"
+import { getUserAndRecruitersDataForOpcoUser, getUserNamesFromIds as getUsersFromIds } from "../../services/user.service"
import {
- createUser,
- getActiveUsers,
+ createAdminUser,
getAdminUsers,
- getAwaitingUsers,
- getDisabledUsers,
- getErrorUsers,
+ getUserRecruteurById,
+ getUsersForAdmin,
removeUser,
sendWelcomeEmailToUserRecruteur,
- updateUser,
- updateUserValidationHistory,
+ updateUser2Fields,
+ userAndRoleAndOrganizationToUserRecruteur,
} from "../../services/userRecruteur.service"
import { Server } from "../server"
@@ -46,9 +56,8 @@ export default (server: Server) => {
onRequest: [server.auth(zRoutes.get["/user"])],
},
async (req, res) => {
- // TODO KEVIN: ADD PAGINATION
- const [awaiting, active, disabled, error] = await Promise.all([getAwaitingUsers(), getActiveUsers(), getDisabledUsers(), getErrorUsers()])
- return res.status(200).send({ awaiting, active, disabled, error })
+ const groupedUsers = await getUsersForAdmin()
+ return res.status(200).send(groupedUsers)
}
)
server.get(
@@ -71,24 +80,10 @@ export default (server: Server) => {
},
async (req, res) => {
const { userId } = req.params
- const userRecruteur = await UserRecruteur.findOne({ _id: userId }).lean()
- let jobs: IJob[] = []
-
- if (!userRecruteur) throw Boom.notFound(`user with id=${userId} not found`)
-
- const { establishment_id } = userRecruteur
- if (userRecruteur.type === ENTREPRISE) {
- if (!establishment_id) {
- throw Boom.internal("Unexpected: no establishment_id in userRecruteur of type ENTREPRISE", { userId: userRecruteur._id })
- }
- const recruiterOpt = await Recruiter.findOne({ establishment_id }).select({ jobs: 1, _id: 0 }).lean()
- if (!recruiterOpt) {
- throw Boom.internal("Get establishement from user failed to fetch", { userId: userRecruteur._id })
- }
- jobs = recruiterOpt.jobs
- }
-
- return res.status(200).send({ ...userRecruteur, jobs })
+ const user = await User2.findById(userId).lean()
+ if (!user) throw Boom.notFound(`user with id=${userId} not found`)
+ const role = await RoleManagement.findOne({ user_id: userId, authorized_type: AccessEntityType.ADMIN }).lean()
+ return res.status(200).send({ ...user, role: role ?? undefined })
}
)
@@ -99,41 +94,36 @@ export default (server: Server) => {
onRequest: [server.auth(zRoutes.post["/admin/users"])],
},
async (req, res) => {
- const user = await createUser({
- ...req.body,
- is_email_checked: true,
- status: [
- {
- status: ETAT_UTILISATEUR.ATTENTE,
- validation_type: VALIDATION_UTILISATEUR.MANUAL,
- user: getUserFromRequest(req, zRoutes.post["/admin/users"]).value._id.toString(),
- },
- ],
+ const { origin, ...userFields } = req.body
+ const userFromRequest = getUserFromRequest(req, zRoutes.post["/admin/users"]).value
+ const user = await createAdminUser(userFields, {
+ origin: origin ?? "",
+ reason: "création par l'interface admin",
+ grantedBy: userFromRequest._id.toString(),
})
- return res.status(200).send(user)
+ return res.status(200).send({ _id: user._id })
}
)
server.put(
- "/admin/users/:userId",
+ "/admin/users/:userId/organization/:siret",
{
- schema: zRoutes.put["/admin/users/:userId"],
- onRequest: [server.auth(zRoutes.put["/admin/users/:userId"])],
+ schema: zRoutes.put["/admin/users/:userId/organization/:siret"],
+ onRequest: [server.auth(zRoutes.put["/admin/users/:userId/organization/:siret"])],
},
async (req, res) => {
- const { email, ...userPayload } = req.body
- const { userId } = req.params
- const formattedEmail = email?.toLocaleLowerCase()
-
- const exist = await UserRecruteur.findOne({ email: formattedEmail, _id: { $ne: userId } }).lean()
-
- if (exist) {
+ const { userId, siret } = req.params
+ const { opco, ...userFields } = req.body
+ const result = await updateUser2Fields(userId, userFields)
+ if ("error" in result) {
return res.status(400).send({ error: true, reason: "EMAIL_TAKEN" })
}
-
- const update = { email: formattedEmail, ...userPayload }
-
- await updateUser({ _id: userId }, update)
+ if (opco) {
+ const entreprise = await Entreprise.findOneAndUpdate({ siret }, { opco }).lean()
+ if (!entreprise) {
+ throw Boom.badRequest(`pas d'entreprise ayant le siret ${siret}`)
+ }
+ }
return res.status(200).send({ ok: true })
}
)
@@ -159,37 +149,63 @@ export default (server: Server) => {
)
server.get(
- "/user/:userId",
+ "/user/:userId/organization/:organizationId",
{
- schema: zRoutes.get["/user/:userId"],
- onRequest: [server.auth(zRoutes.get["/user/:userId"])],
+ schema: zRoutes.get["/user/:userId/organization/:organizationId"],
+ onRequest: [server.auth(zRoutes.get["/user/:userId/organization/:organizationId"])],
},
async (req, res) => {
- const user = await UserRecruteur.findOne({ _id: req.params.userId }).lean()
- const loggedUser = getUserFromRequest(req, zRoutes.get["/user/:userId"]).value
+ const requestUser = getUserFromRequest(req, zRoutes.get["/user/:userId/organization/:organizationId"]).value
+ if (!requestUser) throw Boom.badRequest()
+ const { userId } = req.params
+ const role = await RoleManagement.findOne({
+ user_id: userId,
+ // TODO à activer lorsque le frontend passe organizationId correctement
+ // authorized_id: organizationId,
+ }).lean()
+ if (!role) {
+ throw Boom.badRequest("role not found")
+ }
+ const user = await User2.findOne({ _id: userId }).lean()
+ if (!user) {
+ throw Boom.badRequest("user not found")
+ }
+ const type = roleToUserType(role)
+ if (!type) {
+ throw Boom.internal("user type not found")
+ }
+ let organization: ICFA | IEntreprise | null = null
+ if (type === CFA || type === ENTREPRISE) {
+ organization = await (type === CFA ? Cfa : Entreprise).findOne({ _id: role.authorized_id }).lean()
+ if (!organization) {
+ throw Boom.internal(`inattendu : impossible de trouver l'organization avec id=${role.authorized_id}`)
+ }
+ }
let jobs: IJob[] = []
+ let formulaire: IRecruiter | null = null
- if (!user) throw Boom.badRequest()
-
- if (user.type === ENTREPRISE) {
- const response = await Recruiter.findOne({ establishment_id: user.establishment_id as string })
- .select({ jobs: 1, _id: 0 })
- .lean()
- if (!response) {
- throw Boom.internal("Get establishement from user failed to fetch", { userId: user._id })
- }
- jobs = response.jobs
+ if (type === ENTREPRISE) {
+ formulaire = await getFormulaireFromUserId(userId)
+ jobs = formulaire?.jobs ?? []
}
- // remove status data if not authorized to see it, else get identity
- if ([ENTREPRISE, CFA].includes(loggedUser.type)) {
- user.status = []
- } else {
- user.status = await getValidatorIdentityFromStatus(user.status)
+ const userRecruteur = userAndRoleAndOrganizationToUserRecruteur(user, role, organization, formulaire)
+
+ const opcoOrAdminRole = await RoleManagement.findOne({
+ user_id: requestUser._id,
+ authorized_type: { $in: [AccessEntityType.ADMIN, AccessEntityType.OPCO] },
+ }).lean()
+ if (opcoOrAdminRole && getLastStatusEvent(opcoOrAdminRole.status)?.status === AccessStatus.GRANTED) {
+ const userIds = userRecruteur.status.flatMap(({ user }) => (user ? [user] : []))
+ const users = await getUsersFromIds(userIds)
+ userRecruteur.status.forEach((event) => {
+ const user = users.find((user) => user._id.toString() === event.user)
+ if (!user) return
+ event.user = `${user.first_name} ${user.last_name}`
+ })
}
-
- return res.status(200).send({ ...user, jobs })
+ return res.status(200).send({ ...userRecruteur, jobs })
}
)
@@ -200,7 +216,7 @@ export default (server: Server) => {
onRequest: [server.auth(zRoutes.get["/user/status/:userId"])],
},
async (req, res) => {
- const user = await UserRecruteur.findOne({ _id: req.params.userId }).lean()
+ const user = await getUserRecruteurById(req.params.userId)
if (!user) throw Boom.notFound("User not found")
const status_current = getUserStatus(user.status)
@@ -217,7 +233,7 @@ export default (server: Server) => {
onRequest: [server.auth(zRoutes.get["/user/status/:userId/by-token"])],
},
async (req, res) => {
- const user = await UserRecruteur.findOne({ _id: req.params.userId }).lean()
+ const user = await getUserRecruteurById(req.params.userId)
if (!user) throw Boom.notFound("User not found")
const status_current = getUserStatus(user.status)
@@ -234,41 +250,63 @@ export default (server: Server) => {
onRequest: [server.auth(zRoutes.put["/user/:userId"])],
},
async (req, res) => {
- const { email, ...userPayload } = req.body
const { userId } = req.params
-
- const formattedEmail = email?.toLocaleLowerCase()
-
- const exist = await UserRecruteur.findOne({ email: formattedEmail, _id: { $ne: userId } }).lean()
-
- if (exist) {
+ const result = await updateUser2Fields(userId, req.body)
+ if ("error" in result) {
return res.status(400).send({ error: true, reason: "EMAIL_TAKEN" })
}
-
- const update = { email: formattedEmail, ...userPayload }
-
- const user = await updateUser({ _id: userId }, update)
+ const user = await getUserRecruteurById(userId)
return res.status(200).send(user)
}
)
server.put(
- "/user/:userId/history",
+ "/user/:userId/organization/:organizationId/permission",
{
- schema: zRoutes.put["/user/:userId/history"],
- onRequest: [server.auth(zRoutes.put["/user/:userId/history"])],
+ schema: zRoutes.put["/user/:userId/organization/:organizationId/permission"],
+ onRequest: [server.auth(zRoutes.put["/user/:userId/organization/:organizationId/permission"])],
},
async (req, res) => {
- const history = req.body
- const validator = getUserFromRequest(req, zRoutes.put["/user/:userId/history"]).value
- const user = await updateUserValidationHistory(req.params.userId, { ...history, user: validator._id.toString() })
-
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { reason, status, organizationType } = req.body
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { userId, organizationId } = req.params
+ const requestUser = getUserFromRequest(req, zRoutes.put["/user/:userId/organization/:organizationId/permission"]).value
+ if (!requestUser) throw Boom.badRequest()
+ const user = await User2.findOne({ _id: userId }).lean()
if (!user) throw Boom.badRequest()
+ const roles = await RoleManagement.find({ user_id: userId }).lean()
+ if (roles.length !== 1) {
+ throw Boom.internal(`inattendu : attendu 1 role, ${roles.length} roles trouvés pour user id=${userId}`)
+ }
+ const [mainRole] = roles
+ const updatedRole = await modifyPermissionToUser(
+ {
+ user_id: userId,
+ authorized_id: mainRole.authorized_id,
+ // WARNING : ce code est temporaire tant qu'on sait qu'un user n'a qu'au plus 1 role
+ // authorized_id: organizationId.toString(),
+ authorized_type: mainRole.authorized_type,
+ // authorized_type: organizationType,
+ origin: "action admin ou opco",
+ },
+ {
+ validation_type: VALIDATION_UTILISATEUR.MANUAL,
+ reason,
+ status,
+ granted_by: requestUser._id.toString(),
+ }
+ )
+
const { email, last_name, first_name } = user
+ const newEvent = getLastStatusEvent(updatedRole.status)
+ if (!newEvent) {
+ throw Boom.internal("inattendu : aucun event sauvegardé")
+ }
// if user is disabled, return the user data directly
- if (history.status === ETAT_UTILISATEUR.DESACTIVE) {
+ if (newEvent.status === AccessStatus.DENIED) {
// send email to user to notify him his account has been disabled
await mailer.sendEmail({
to: email,
@@ -281,38 +319,33 @@ export default (server: Server) => {
},
last_name: sanitizeForEmail(last_name),
first_name: sanitizeForEmail(first_name),
- reason: sanitizeForEmail(history.reason),
+ reason: sanitizeForEmail(newEvent.reason),
emailSupport: "mailto:labonnealternance@apprentissage.beta.gouv.fr?subject=Compte%20pro%20non%20validé",
},
})
- return res.status(200).send(user)
+ return res.status(200).send({})
}
/**
* 20230831 kevin todo: share reason between front and back with shared folder
*/
- // if user isn't part of the OPCO, just send the user straigth back
- if (history.reason === "Ne relève pas des champs de compétences de mon OPCO") {
- return res.status(200).send(user)
+ // if user isn't part of the OPCO, just send the user straight back
+ if (newEvent.reason === "Ne relève pas des champs de compétences de mon OPCO") {
+ return res.status(200).send({})
}
- if (user.type === ENTREPRISE) {
- const { establishment_id } = user
- if (!establishment_id) {
- throw Boom.internal("unexpected: no establishment_id on userRecruteur of type ENTREPRISE", { userId: user._id })
- }
+ if (mainRole.authorized_type === AccessEntityType.ENTREPRISE) {
/**
* if entreprise type of user is validated :
* - activate offer
* - update expiration date to one month later
* - send email to delegation if available
*/
- const userFormulaire = await getFormulaire({ establishment_id })
-
+ const userFormulaire = await getFormulaireFromUserIdOrError(user._id.toString())
if (userFormulaire.status === RECRUITER_STATUS.ARCHIVE) {
// le recruiter étant archivé on se contente de le rendre de nouveau Actif
- await reactivateRecruiter(establishment_id)
- } else {
+ await reactivateRecruiter(userFormulaire._id)
+ } else if (userFormulaire.status === RECRUITER_STATUS.ACTIF) {
// le compte se trouve validé, on procède à l'activation de la première offre et à la notification aux CFAs
if (userFormulaire?.jobs?.length) {
await activateEntrepriseRecruiterForTheFirstTime(userFormulaire)
@@ -321,9 +354,9 @@ export default (server: Server) => {
}
// validate user email addresse
- await updateUser({ _id: user._id }, { is_email_checked: true })
+ await validateUser2Email(user._id.toString())
await sendWelcomeEmailToUserRecruteur(user)
- return res.status(200).send(user)
+ return res.status(200).send({})
}
)
diff --git a/server/src/http/sentry.ts b/server/src/http/sentry.ts
index f0213060a4..42e14d8ce3 100644
--- a/server/src/http/sentry.ts
+++ b/server/src/http/sentry.ts
@@ -53,7 +53,6 @@ function extractUserData(request: FastifyRequest) {
segment: "access-token",
id: "_id" in identity ? identity._id.toString() : identity.email,
email: identity.email,
- type: identity.type,
}
}
@@ -61,7 +60,6 @@ function extractUserData(request: FastifyRequest) {
segment: "session",
id: user.value._id.toString(),
email: user.value.email,
- type: user.value.type,
}
}
diff --git a/server/src/http/utils/rateLimiters.ts b/server/src/http/utils/rateLimiters.ts
deleted file mode 100644
index 261dd8bc87..0000000000
--- a/server/src/http/utils/rateLimiters.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import rateLimit from "express-rate-limit"
-
-import config from "@/config"
-
-let skip = config.env === "local"
-
-export const enableRateLimiter = () => {
- skip = false
-}
-
-export const limiter3PerSecond = rateLimit({
- windowMs: 1000, // 1 second
- max: 3, // limit each IP to 3 requests per windowMs
- skip: () => skip,
-})
-
-export const limiter1Per20Second = rateLimit({
- windowMs: 20000, // 20 seconds
- max: 1, // limit each IP to 1 request per windowMs
- skip: () => skip,
-})
-
-export const limiter5PerSecond = rateLimit({
- windowMs: 1000, // 1 second
- max: 5, // limit each IP to 5 requests per windowMs
- skip: () => skip,
-})
-export const limiter7PerSecond = rateLimit({
- windowMs: 1000, // 1 second
- max: 7, // limit each IP to 7 requests per windowMs
- skip: () => skip,
-})
-export const limiter10PerSecond = rateLimit({
- windowMs: 1000, // 1 second
- max: 10, // limit each IP to 10 requests per windowMs
- skip: () => skip,
-})
-
-export const limiter20PerSecond = rateLimit({
- windowMs: 1000, // 1 second
- max: 20, // limit each IP to 20 requests per windowMs
- skip: () => skip,
-})
diff --git a/server/src/jobs/anonymization/anonymizeIndividual.ts b/server/src/jobs/anonymization/anonymizeIndividual.ts
index 9ca57e5e0a..dbea382c3c 100644
--- a/server/src/jobs/anonymization/anonymizeIndividual.ts
+++ b/server/src/jobs/anonymization/anonymizeIndividual.ts
@@ -1,44 +1,27 @@
-import pkg from "mongodb"
-import { CFA, ENTREPRISE } from "shared/constants/recruteur"
-
import { logger } from "../../common/logger"
-import { AnonymizedUser, Application, Recruiter, User, UserRecruteur } from "../../common/model/index"
-
-const { ObjectId } = pkg
+import { AnonymizedUser, Application, Recruiter, User, User2 } from "../../common/model/index"
-const anonimizeUserRecruteur = (_id: string) =>
- UserRecruteur.aggregate([
+const anonimizeUser2 = (_id: string) =>
+ User2.aggregate([
{
$match: { _id },
},
{
$project: {
- opco: 1,
- idcc: 1,
- establishment_raison_sociale: 1,
- establishment_enseigne: 1,
- establishment_siret: 1,
- address_detail: 1,
- address: 1,
- geo_coordinates: 1,
- scope: 1,
- is_email_checked: 1,
- type: 1,
- establishment_id: 1,
- last_connection: 1,
+ last_action_date: 1,
origin: 1,
status: 1,
- is_qualiopi: 1,
},
},
{
- $merge: "anonymizeduserrecruteurs",
+ $merge: "anonymizeduser2s",
},
])
-const anonimizeRecruiter = (query: object) =>
+
+const anonimizeRecruiterByUserId = (userId: string) =>
Recruiter.aggregate([
{
- $match: query,
+ $match: { "jobs.managed_by": userId },
},
{
$project: {
@@ -68,12 +51,12 @@ const anonimizeRecruiter = (query: object) =>
])
const deleteRecruiter = (query) => Recruiter.deleteMany(query)
-const deleteUserRecruteur = (query) => UserRecruteur.deleteMany(query)
+const deleteUser2 = (query) => User2.deleteMany(query)
const anonymizeApplication = async (_id: string) => {
await Application.aggregate([
{
- $match: { _id: new ObjectId(_id) },
+ $match: { _id },
},
{
$project: {
@@ -115,28 +98,13 @@ const anonymizeUser = async (_id: string) => {
}
}
-const anonymizeUserRecruterAndRecruiter = async (_id: string) => {
- const user = await UserRecruteur.findById(_id).lean()
-
+const anonymizeUser2AndRecruiter = async (userId: string) => {
+ const user = await User2.findById(userId)
if (!user) {
- throw new Error("Anonymize userRecruter not found")
- }
-
- switch (user.type) {
- case ENTREPRISE:
- await Promise.all([anonimizeUserRecruteur(user._id.toString()), anonimizeRecruiter({ establishment_id: user.establishment_id })])
- await Promise.all([deleteUserRecruteur({ _id: user._id }), deleteRecruiter({ establishment_id: user.establishment_id })])
-
- break
- case CFA:
- await Promise.all([anonimizeUserRecruteur(user._id.toString()), anonimizeRecruiter({ cfa_delegated_siret: user.establishment_siret })])
- await Promise.all([deleteUserRecruteur({ _id: user._id }), deleteRecruiter({ cfa_delegated_siret: user.establishment_siret })])
-
- break
-
- default:
- throw new Error(`Anonymize ${user.type} is not permitted. script must be updated manually to delete this type of user.`)
+ throw new Error("Anonymize user not found")
}
+ await Promise.all([anonimizeUser2(userId), anonimizeRecruiterByUserId(userId)])
+ await Promise.all([deleteUser2({ _id: userId }), deleteRecruiter({ "jobs.managed_by": userId })])
}
export async function anonymizeIndividual({ collection, id }: { collection: string; id: string }): Promise {
@@ -150,7 +118,7 @@ export async function anonymizeIndividual({ collection, id }: { collection: stri
break
}
case "userrecruteurs": {
- await anonymizeUserRecruterAndRecruiter(id)
+ await anonymizeUser2AndRecruiter(id)
break
}
default:
diff --git a/server/src/jobs/anonymization/anonymizeUserRecruteurs.ts b/server/src/jobs/anonymization/anonymizeUserRecruteurs.ts
index 6db6e3a497..0f45418e3a 100644
--- a/server/src/jobs/anonymization/anonymizeUserRecruteurs.ts
+++ b/server/src/jobs/anonymization/anonymizeUserRecruteurs.ts
@@ -1,40 +1,28 @@
import dayjs from "dayjs"
import { logger } from "../../common/logger"
-import { Recruiter, UserRecruteur } from "../../common/model/index"
+import { Recruiter, User2 } from "../../common/model/index"
import { notifyToSlack } from "../../common/utils/slackUtils"
const anonymize = async () => {
const fromDate = dayjs().subtract(2, "years").toDate()
- const userRecruteurQuery = { $or: [{ last_connection: { $lte: fromDate } }, { last_connection: null, createdAt: { $lte: fromDate } }] }
- const usersToAnonymize = await UserRecruteur.find(userRecruteurQuery).lean()
- const establishmentIds = usersToAnonymize.flatMap(({ establishment_id }) => (establishment_id ? [establishment_id] : []))
- const recruiterQuery = { establishment_id: { $in: establishmentIds } }
- await UserRecruteur.aggregate([
+ const user2Query = { $or: [{ last_action_date: { $lte: fromDate } }, { last_action_date: null, createdAt: { $lte: fromDate } }] }
+ const usersToAnonymize = await User2.find(user2Query).lean()
+ const userIds = usersToAnonymize.map(({ _id }) => _id.toString())
+ const recruiterQuery = { "jobs.managed_by": { $in: userIds } }
+ await User2.aggregate([
{
- $match: userRecruteurQuery,
+ $match: user2Query,
},
{
$project: {
- opco: 1,
- idcc: 1,
- establishment_raison_sociale: 1,
- establishment_enseigne: 1,
- establishment_siret: 1,
- address_detail: 1,
- address: 1,
- geo_coordinates: 1,
- is_email_checked: 1,
- type: 1,
- establishment_id: 1,
- last_connection: 1,
+ last_action_date: 1,
origin: 1,
status: 1,
- is_qualiopi: 1,
},
},
{
- $merge: "anonymizeduserrecruteurs",
+ $merge: "anonymizeduser2s",
},
])
await Recruiter.aggregate([
@@ -68,20 +56,20 @@ const anonymize = async () => {
},
])
const { deletedCount: recruiterCount } = await Recruiter.deleteMany(recruiterQuery)
- const { deletedCount: userRecruteurCount } = await UserRecruteur.deleteMany(userRecruteurQuery)
- return { userRecruteurCount, recruiterCount }
+ const { deletedCount: user2Count } = await User2.deleteMany(user2Query)
+ return { user2Count, recruiterCount }
}
export async function anonimizeUserRecruteurs() {
- const subject = "ANONYMISATION DES USER RECRUTEURS et RECRUITERS"
+ const subject = "ANONYMISATION DES USERS et RECRUITERS"
try {
- logger.info(" -- Anonymisation des user recruteurs de plus de 2 ans -- ")
+ logger.info(" -- Anonymisation des users de plus de 2 ans -- ")
- const { recruiterCount, userRecruteurCount } = await anonymize()
+ const { recruiterCount, user2Count } = await anonymize()
await notifyToSlack({
subject,
- message: `Anonymisation des user recruteurs de plus de 2 ans terminée. ${userRecruteurCount} user recruteur(s) anonymisé(s). ${recruiterCount} recruiter(s) anonymisé(s)`,
+ message: `Anonymisation des users de plus de 2 ans terminée. ${user2Count} user(s) anonymisé(s). ${recruiterCount} recruiter(s) anonymisé(s)`,
error: false,
})
} catch (err: any) {
diff --git a/server/src/jobs/database/fixDiffusibleCompanies.ts b/server/src/jobs/database/fixDiffusibleCompanies.ts
new file mode 100644
index 0000000000..e6a3c53a19
--- /dev/null
+++ b/server/src/jobs/database/fixDiffusibleCompanies.ts
@@ -0,0 +1,187 @@
+import { ILbaCompany, IRecruiter, JOB_STATUS } from "shared"
+import { EDiffusibleStatus } from "shared/constants/diffusibleStatus"
+import { RECRUITER_STATUS } from "shared/constants/recruteur"
+import { IEntreprise } from "shared/models/entreprise.model"
+import { AccessEntityType } from "shared/models/roleManagement.model"
+
+import { logger } from "@/common/logger"
+import { Entreprise, Recruiter, RoleManagement } from "@/common/model"
+import { db } from "@/common/mongodb"
+import { getDiffusionStatus } from "@/services/etablissement.service"
+
+const ANONYMIZED = "anonymized"
+const FAKE_GEOLOCATION = "0,0"
+
+const fixLbaCompanies = async () => {
+ logger.info(`Fixing diffusible lba companies`)
+ const lbaCompanies: AsyncIterable = await db.collection("bonnesboites").find({})
+
+ let count = 0
+ let deletedCount = 0
+ let errorCount = 0
+ for await (const lbaCompany of lbaCompanies) {
+ if (count % 500 === 0) {
+ logger.info(`${count} companies checked. ${deletedCount} removed. ${errorCount} errors`)
+ }
+ count++
+ try {
+ const isDiffusible = await getDiffusionStatus(lbaCompany.siret)
+
+ if (isDiffusible !== EDiffusibleStatus.DIFFUSIBLE) {
+ await db.collection("bonnesboites").deleteOne({ siret: lbaCompany.siret })
+ deletedCount++
+ }
+ } catch (err) {
+ errorCount++
+ console.log(err)
+ break
+ }
+ }
+ logger.info(`Final result : ${count} companies checked. ${deletedCount} removed. ${errorCount} errors`)
+
+ logger.info(`Fixing lba companies done`)
+}
+
+const deactivateRecruiter = async (recruiter: IRecruiter) => {
+ console.info("deactivating non diffusible recruiter : ", recruiter.establishment_siret)
+ recruiter.status = RECRUITER_STATUS.ARCHIVE
+ recruiter.address = ANONYMIZED
+ recruiter.geo_coordinates = FAKE_GEOLOCATION
+ recruiter.address_detail = recruiter.address_detail
+ ? { status_diffusion: recruiter.address_detail.status_diffusion, libelle_commune: ANONYMIZED }
+ : { libelle_commune: ANONYMIZED }
+
+ for await (const job of recruiter.jobs) {
+ job.job_status = JOB_STATUS.ACTIVE ? JOB_STATUS.ANNULEE : job.job_status
+ }
+
+ await Recruiter.updateOne({ _id: recruiter._id }, { $set: { ...recruiter } })
+}
+
+const deactivateEntreprise = async (entreprise: IEntreprise) => {
+ const { siret } = entreprise
+ console.info("deactivating non diffusible entreprise : ", siret)
+ await Entreprise.deleteOne({ _id: entreprise._id })
+ await RoleManagement.deleteMany({ authorized_type: AccessEntityType.ENTREPRISE, authorized_id: entreprise._id.toString() })
+}
+
+const fixRecruiters = async () => {
+ logger.info(`Fixing diffusible recruiters and offers`)
+ const recruiters: AsyncIterable = await db.collection("recruiters").find({})
+
+ let count = 0
+ let deactivatedCount = 0
+ let errorCount = 0
+ for await (const recruiter of recruiters) {
+ if (count % 100 === 0) {
+ logger.info(`${count} recruiters checked. ${deactivatedCount} removed. ${errorCount} errors`)
+ }
+ count++
+ try {
+ const isDiffusible = await getDiffusionStatus(recruiter.establishment_siret)
+
+ if (isDiffusible !== EDiffusibleStatus.DIFFUSIBLE) {
+ deactivateRecruiter(recruiter)
+
+ deactivatedCount++
+ }
+ } catch (err) {
+ errorCount++
+ console.log(err)
+ break
+ }
+ }
+
+ const entreprises: AsyncIterable = await db.collection("entreprises").find({})
+
+ count = 0
+ deactivatedCount = 0
+ errorCount = 0
+
+ for await (const entreprise of entreprises) {
+ if (count % 100 === 0) {
+ logger.info(`${count} entreprises checked. ${deactivatedCount} removed. ${errorCount} errors`)
+ }
+ count++
+ try {
+ const { siret } = entreprise
+ const isDiffusible = siret ? await getDiffusionStatus(siret) : EDiffusibleStatus.NOT_FOUND
+
+ if (siret && isDiffusible !== EDiffusibleStatus.DIFFUSIBLE) {
+ deactivateEntreprise(entreprise)
+
+ deactivatedCount++
+ }
+ } catch (err) {
+ errorCount++
+ console.log(err)
+ break
+ }
+ }
+}
+
+export async function fixDiffusibleCompanies(payload: { collection_list?: string }): Promise {
+ const collectionList = payload?.collection_list ?? "lbacompanies,recruiters"
+ const list = collectionList.split(",")
+
+ if (list.includes("lbacompanies")) {
+ await fixLbaCompanies()
+ }
+
+ if (list.includes("recruiters")) {
+ await fixRecruiters()
+ }
+}
+
+export async function checkDiffusibleCompanies(): Promise {
+ logger.info(`Checking diffusible sirets`)
+ const sirets: AsyncIterable<{ _id: string }> = await db.collection("tmp_siret").find({})
+
+ let count = 0
+ let nonDiffusibleCount = 0
+ let partiellementDiffusibleCount = 0
+ let unavailableCount = 0
+ let notFoundCount = 0
+ let errorCount = 0
+
+ for await (const { _id } of sirets) {
+ if (count % 100 === 0) {
+ logger.info(
+ `${count} sirets checked. ${partiellementDiffusibleCount} partDiff. ${unavailableCount} indisp. ${notFoundCount} non trouvé. ${nonDiffusibleCount} nonDiff. ${errorCount} errors`
+ )
+ }
+ count++
+ try {
+ const isDiffusible = await getDiffusionStatus(_id)
+
+ switch (isDiffusible) {
+ case EDiffusibleStatus.NON_DIFFUSIBLE: {
+ nonDiffusibleCount++
+ break
+ }
+ case EDiffusibleStatus.PARTIELLEMENT_DIFFUSIBLE: {
+ partiellementDiffusibleCount++
+ break
+ }
+ case EDiffusibleStatus.UNAVAILABLE: {
+ unavailableCount++
+ break
+ }
+ case EDiffusibleStatus.NOT_FOUND: {
+ notFoundCount++
+ break
+ }
+ default:
+ }
+ } catch (err) {
+ errorCount++
+ console.log(err)
+ break
+ }
+ }
+ logger.info(
+ `FIN : ${count} companies checked. ${partiellementDiffusibleCount} partDiff. ${unavailableCount} indisp. ${notFoundCount} non trouvé. ${nonDiffusibleCount} nonDiff. ${errorCount} errors`
+ )
+
+ logger.info(`Checking sirets done`)
+}
diff --git a/server/src/jobs/database/validateModels.ts b/server/src/jobs/database/validateModels.ts
index f72768356e..c622f6dc68 100644
--- a/server/src/jobs/database/validateModels.ts
+++ b/server/src/jobs/database/validateModels.ts
@@ -24,6 +24,10 @@ import {
ZUserRecruteur,
zFormationCatalogueSchema,
} from "shared/models"
+import { zCFA } from "shared/models/cfa.model"
+import { ZEntreprise } from "shared/models/entreprise.model"
+import { ZRoleManagement } from "shared/models/roleManagement.model"
+import { ZUser2 } from "shared/models/user2.model"
import { ZodType } from "zod"
import { logger } from "@/common/logger"
@@ -32,11 +36,13 @@ import {
Application,
Appointment,
AppointmentDetailed,
+ Cfa,
Credential,
DiplomesMetiers,
DomainesMetiers,
EligibleTrainingsForAppointment,
EmailBlacklist,
+ Entreprise,
Etablissement,
FormationCatalogue,
GeoLocation,
@@ -47,9 +53,11 @@ import {
Recruiter,
ReferentielOnisep,
ReferentielOpco,
+ RoleManagement,
UnsubscribeOF,
UnsubscribedLbaCompany,
User,
+ User2,
UserRecruteur,
eligibleTrainingsForAppointmentHistory,
} from "@/common/model/index"
@@ -120,6 +128,10 @@ export async function validateModels(): Promise {
await validateModel(ReferentielOpco, ZReferentielOpco)
await validateModel(UnsubscribeOF, ZUnsubscribeOF)
await validateModel(UnsubscribedLbaCompany, ZUnsubscribedLbaCompany)
- await validateModel(UserRecruteur, ZUserRecruteur)
await validateModel(eligibleTrainingsForAppointmentHistory, ZEligibleTrainingsForAppointmentSchema)
+ await validateModel(UserRecruteur, ZUserRecruteur)
+ await validateModel(Entreprise, ZEntreprise)
+ await validateModel(Cfa, zCFA)
+ await validateModel(User2, ZUser2)
+ await validateModel(RoleManagement, ZRoleManagement)
}
diff --git a/server/src/jobs/jobs.ts b/server/src/jobs/jobs.ts
index a3d43f1c82..18cebe2253 100644
--- a/server/src/jobs/jobs.ts
+++ b/server/src/jobs/jobs.ts
@@ -29,7 +29,6 @@ import { fixJobExpirationDate } from "./lba_recruteur/formulaire/fixJobExpiratio
import { fixJobType } from "./lba_recruteur/formulaire/fixJobType"
import { fixRecruiterDataValidation } from "./lba_recruteur/formulaire/fixRecruiterDataValidation"
import { exportToFranceTravail } from "./lba_recruteur/formulaire/misc/exportToFranceTravail"
-import { recoverMissingGeocoordinates } from "./lba_recruteur/formulaire/misc/recoverGeocoordinates"
import { removeIsDelegatedFromJobs } from "./lba_recruteur/formulaire/misc/removeIsDelegatedFromJobs"
import { repiseGeocoordinates } from "./lba_recruteur/formulaire/misc/repriseGeocoordinates"
import { resendDelegationEmailWithAccessToken } from "./lba_recruteur/formulaire/misc/sendDelegationEmailWithSecuredToken"
@@ -39,16 +38,13 @@ import { relanceFormulaire } from "./lba_recruteur/formulaire/relanceFormulaire"
import { importReferentielOpcoFromConstructys } from "./lba_recruteur/opco/constructys/constructysImporter"
import { relanceOpco } from "./lba_recruteur/opco/relanceOpco"
import { createOffreCollection } from "./lba_recruteur/seed/createOffre"
-import { fillRecruiterRaisonSociale } from "./lba_recruteur/user/misc/fillRecruiterRaisonSociale"
-import { fixUserRecruiterCfaDataValidation } from "./lba_recruteur/user/misc/fixUserRecruteurCfaDataValidation"
-import { fixUserRecruiterDataValidation } from "./lba_recruteur/user/misc/fixUserRecruteurDataValidation"
-import { checkAwaitingCompaniesValidation } from "./lba_recruteur/user/misc/updateMissingActivationState"
import { updateSiretInfosInError } from "./lba_recruteur/user/misc/updateSiretInfosInError"
import buildSAVE from "./lbb/buildSAVE"
import updateGeoLocations from "./lbb/updateGeoLocations"
import updateLbaCompanies from "./lbb/updateLbaCompanies"
import updateOpcoCompanies from "./lbb/updateOpcoCompanies"
import { runGarbageCollector } from "./misc/runGarbageCollector"
+import { migrationUsers } from "./multiCompte/migrationUsers"
import { activateOptoutOnEtablissementAndUpdateReferrersOnETFA } from "./rdv/activateOptoutOnEtablissementAndUpdateReferrersOnETFA"
import { anonimizeAppointments } from "./rdv/anonymizeAppointments"
import { anonymizeOldUsers } from "./rdv/anonymizeUsers"
@@ -82,14 +78,14 @@ export const CronsMap = {
cron_string: "15 0 * * *",
handler: () => addJob({ name: "formulaire:annulation", payload: {} }),
},
- "Send offer reminder email at J+7": {
- cron_string: "20 0 * * *",
- handler: () => addJob({ name: "formulaire:relance", payload: { threshold: "7" } }),
- },
- "Send offer reminder email at J+1": {
- cron_string: "25 0 * * *",
- handler: () => addJob({ name: "formulaire:relance", payload: { threshold: "1" } }),
- },
+ // "Send offer reminder email at J+7": {
+ // cron_string: "20 0 * * *",
+ // handler: () => addJob({ name: "formulaire:relance", payload: { threshold: "7" } }),
+ // },
+ // "Send offer reminder email at J+1": {
+ // cron_string: "25 0 * * *",
+ // handler: () => addJob({ name: "formulaire:relance", payload: { threshold: "1" } }),
+ // },
"Send reminder to OPCO about awaiting validation users": {
cron_string: "30 0 * * 1,3,5",
handler: () => addJob({ name: "opco:relance", payload: { threshold: "1" } }),
@@ -252,8 +248,6 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple):
return repiseGeocoordinates()
case "recruiters:get-missing-address-detail":
return updateAddressDetailOnRecruitersCollection()
- case "migration:get-missing-geocoords": // Temporaire, doit tourner en recette et production
- return recoverMissingGeocoordinates()
case "import:rome":
return importFicheMetierRomeV3()
case "migration:remove-version-key-from-all-collections": // Temporaire, doit tourner en recette et production
@@ -303,8 +297,6 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple):
return relanceOpco()
case "pe:offre:export":
return exportToFranceTravail()
- case "user:validate":
- return checkAwaitingCompaniesValidation()
case "siret:inError:update":
return updateSiretInfosInError()
case "etablissement:formations:activate:opt-out":
@@ -315,10 +307,14 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple):
return inviteEtablissementParcoursupToPremium()
case "etablissement:invite:premium:affelnet":
return inviteEtablissementAffelnetToPremium()
- case "etablissement:invite:premium:follow-up":
- return inviteEtablissementParcoursupToPremiumFollowUp()
- case "etablissement:invite:premium:affelnet:follow-up":
- return inviteEtablissementAffelnetToPremiumFollowUp()
+ case "etablissement:invite:premium:follow-up": {
+ const { bypassDate } = job.payload
+ return inviteEtablissementParcoursupToPremiumFollowUp(bypassDate)
+ }
+ case "etablissement:invite:premium:affelnet:follow-up": {
+ const { bypassDate } = job.payload
+ return inviteEtablissementAffelnetToPremiumFollowUp(bypassDate)
+ }
case "premium:activated:reminder":
return premiumActivatedReminder()
case "premium:invite:one-shot":
@@ -359,8 +355,6 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple):
}
case "diplomes-metiers:update":
return updateDiplomesMetiers()
- case "recruiters:raison-sociale:fill":
- return fillRecruiterRaisonSociale()
case "recruiters:expiration-date:fix":
return fixJobExpirationDate()
case "recruiters:job-type:fix":
@@ -369,14 +363,13 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple):
return fixApplications()
case "recruiters:data-validation:fix":
return fixRecruiterDataValidation()
- case "user-recruters:data-validation:fix":
- return fixUserRecruiterDataValidation()
- case "user-recruters-cfa:data-validation:fix":
- return fixUserRecruiterCfaDataValidation()
case "referentiel-opco:constructys:import": {
const { parallelism } = job.payload
return importReferentielOpcoFromConstructys(parseInt(parallelism))
}
+ case "migrate-multi-compte": {
+ return migrationUsers()
+ }
case "prdv:emails:resend": {
const { fromDate } = job.payload
return repriseEmailRdvs({ fromDateStr: fromDate })
diff --git a/server/src/jobs/lba_recruteur/formulaire/createUser.ts b/server/src/jobs/lba_recruteur/formulaire/createUser.ts
index 3108e81589..e16e309f5e 100644
--- a/server/src/jobs/lba_recruteur/formulaire/createUser.ts
+++ b/server/src/jobs/lba_recruteur/formulaire/createUser.ts
@@ -1,8 +1,9 @@
-import { ETAT_UTILISATEUR } from "shared/constants/recruteur"
+import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur"
import { IUserRecruteur } from "shared/models"
+import { AccessStatus } from "shared/models/roleManagement.model"
import { logger } from "../../../common/logger"
-import { getUser, createUser } from "../../../services/userRecruteur.service"
+import { createUser, getUserRecruteurByEmail } from "../../../services/userRecruteur.service"
export const createUserFromCLI = async (
{
@@ -18,33 +19,33 @@ export const createUserFromCLI = async (
{ options }: { options: { Type: IUserRecruteur["type"]; Email_valide: IUserRecruteur["is_email_checked"] } }
) => {
const { Type, Email_valide } = options
- const exist = await getUser({ email })
+ const exist = await getUserRecruteurByEmail(email)
if (exist) {
logger.error(`Users ${email} already exist - ${exist._id}`)
return
}
- await createUser({
- first_name,
- last_name,
- establishment_siret,
- establishment_raison_sociale,
- phone,
- address,
- email,
- scope,
- type: Type,
- is_email_checked: Email_valide,
- status: [
- {
- status: ETAT_UTILISATEUR.VALIDE,
- validation_type: "AUTOMATIQUE",
- user: "SERVEUR",
- date: new Date(),
- },
- ],
- })
+ await createUser(
+ {
+ first_name,
+ last_name,
+ establishment_siret,
+ establishment_raison_sociale,
+ phone,
+ address,
+ email,
+ scope,
+ type: Type,
+ is_email_checked: Email_valide,
+ },
+ "CLI",
+ {
+ reason: "created from CLI",
+ status: AccessStatus.GRANTED,
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ }
+ )
logger.info(`User created : ${email} — ${scope} - admin: ${Type === "ADMIN"}`)
}
diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/UpdateEmailToLowerCase.ts b/server/src/jobs/lba_recruteur/formulaire/misc/UpdateEmailToLowerCase.ts
deleted file mode 100644
index b72c631684..0000000000
--- a/server/src/jobs/lba_recruteur/formulaire/misc/UpdateEmailToLowerCase.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { ETAT_UTILISATEUR, RECRUITER_STATUS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur"
-
-import { logger } from "../../../../common/logger"
-import { Recruiter, UserRecruteur } from "../../../../common/model/index"
-import { asyncForEach } from "../../../../common/utils/asyncUtils"
-import { runScript } from "../../../scriptWrapper"
-
-function hasUpperCase(str) {
- return str !== str.toLowerCase()
-}
-
-runScript(async () => {
- const users = await UserRecruteur.find({})
- const userToUpdate = users.filter((x) => hasUpperCase(x.email))
- const stat = { hasSibblingLowerCase: 0, total: users.length }
-
- logger.info(`${userToUpdate.length} utilisateur à mettre à jour`)
-
- await asyncForEach(userToUpdate, async (user) => {
- const exist = await UserRecruteur.findOne({ email: user.email.toLowerCase() })
-
- if (exist) {
- stat.hasSibblingLowerCase++
-
- await UserRecruteur.findOneAndUpdate(
- { email: user.email },
- {
- $push: {
- status: {
- validation_type: VALIDATION_UTILISATEUR.AUTO,
- status: ETAT_UTILISATEUR.DESACTIVE,
- reason: `Utilisateur en doublon (traitement des majuscules ${new Date()}`,
- user: "SERVEUR",
- },
- },
- }
- )
- const { establishment_id } = user
- if (establishment_id) {
- await Recruiter.findOneAndUpdate({ establishment_id }, { $set: { status: RECRUITER_STATUS.ARCHIVE } })
- }
- return
- } else {
- user.email = user.email.toLowerCase()
- await user.save()
- }
- })
- return stat
-})
diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/addEnseigne.ts b/server/src/jobs/lba_recruteur/formulaire/misc/addEnseigne.ts
deleted file mode 100644
index b9c5967dc4..0000000000
--- a/server/src/jobs/lba_recruteur/formulaire/misc/addEnseigne.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import Boom from "boom"
-
-import { Recruiter, UserRecruteur } from "../../../../common/model/index"
-import { getEtablissementFromGouv } from "../../../../services/etablissement.service"
-import { runScript } from "../../../scriptWrapper"
-
-runScript(async () => {
- const errors: any[] = []
- const itemsUpdated = {}
- const [formulaires, users] = await Promise.all([Recruiter.find({ siret: { $exists: true } }).lean(), UserRecruteur.find({ siret: { $exists: true } }).lean()])
-
- for (const formulaire of formulaires) {
- try {
- const data = await getEtablissementFromGouv(formulaire.establishment_siret)
- const enseigneFromApiEntreprise = data?.data.enseigne
- if (enseigneFromApiEntreprise) {
- await Recruiter.findOneAndUpdate({ _id: formulaire._id }, { establishment_enseigne: enseigneFromApiEntreprise })
- itemsUpdated[`${formulaire.establishment_siret}`] = enseigneFromApiEntreprise
- }
- } catch (error: any) {
- errors.push(error)
- }
- }
-
- for (const user of users) {
- try {
- if (!user.establishment_siret) {
- throw Boom.internal("unexpected: no establishment_siret on userRecruteur", { userId: user._id })
- }
- const data = await getEtablissementFromGouv(user.establishment_siret)
-
- const enseigneFromApiEntreprise = data?.data.enseigne
-
- if (enseigneFromApiEntreprise) {
- await UserRecruteur.findOneAndUpdate({ _id: user._id }, { establishment_enseigne: enseigneFromApiEntreprise })
- itemsUpdated[`${user.establishment_siret}`] = enseigneFromApiEntreprise
- }
- } catch (error: any) {
- errors.push(error)
- }
- }
-
- return {
- errors,
- itemsUpdated,
- }
-})
diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/exportToFranceTravail.ts b/server/src/jobs/lba_recruteur/formulaire/misc/exportToFranceTravail.ts
index fec4b149dd..0ee57fb0e6 100644
--- a/server/src/jobs/lba_recruteur/formulaire/misc/exportToFranceTravail.ts
+++ b/server/src/jobs/lba_recruteur/formulaire/misc/exportToFranceTravail.ts
@@ -2,7 +2,6 @@ import { createWriteStream } from "fs"
import path from "path"
import { Readable } from "stream"
-import { pick } from "lodash-es"
import { oleoduc, transformData, transformIntoCSV } from "oleoduc"
import { RECRUITER_STATUS } from "shared/constants/recruteur"
import { JOB_STATUS } from "shared/models"
@@ -11,7 +10,7 @@ import { db } from "@/common/mongodb"
import { sendCsvToFranceTravail } from "../../../../common/apis/FranceTravail"
import { logger } from "../../../../common/logger"
-import { UserRecruteur } from "../../../../common/model/index"
+import { Cfa } from "../../../../common/model/index"
import { getDepartmentByZipCode } from "../../../../common/territoires"
import { asyncForEach } from "../../../../common/utils/asyncUtils"
import { notifyToSlack } from "../../../../common/utils/slackUtils"
@@ -189,12 +188,13 @@ export const exportToFranceTravail = async (): Promise => {
logger.info(`get info from ${offres.length} offers...`)
await asyncForEach(offres, async (offre) => {
- const user = offre.is_delegated ? await UserRecruteur.findOne({ establishment_siret: offre.cfa_delegated_siret }) : null
+ const cfa = offre.is_delegated ? await Cfa.findOne({ siret: offre.cfa_delegated_siret }) : null
if (typeof offre.rome_detail !== "string" && offre.rome_detail) {
offre.job_type.map(async (type) => {
if (offre.rome_detail && typeof offre.rome_detail !== "string") {
- buffer.push({ ...offre, type: type, cfa: user ? pick(user, ["address_detail", "establishment_raison_sociale"]) : null })
+ const cfaFields = cfa ? { address_detail: cfa.address_detail, establishment_raison_sociale: cfa.raison_sociale } : null
+ buffer.push({ ...offre, type, cfa: cfaFields })
}
})
}
diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/recoverGeocoordinates.ts b/server/src/jobs/lba_recruteur/formulaire/misc/recoverGeocoordinates.ts
deleted file mode 100644
index 79fd82ddf7..0000000000
--- a/server/src/jobs/lba_recruteur/formulaire/misc/recoverGeocoordinates.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { ENTREPRISE } from "shared/constants/recruteur"
-
-import { logger } from "../../../../common/logger"
-import { Recruiter, UserRecruteur } from "../../../../common/model"
-import { asyncForEach, sleep } from "../../../../common/utils/asyncUtils"
-import { GeoCoord, getGeoCoordinates } from "../../../../services/etablissement.service"
-
-const recoverMissingGeocoordinatesUserRecruteur = async () => {
- const users = await UserRecruteur.find({ geo_coordinates: "NOT FOUND", type: ENTREPRISE })
-
- await asyncForEach(users, async (user) => {
- if (!user.address_detail) return
- await sleep(500)
-
- let geocoord: GeoCoord | null
- if ("l4" in user.address_detail) {
- // if address data is in API address V2
- geocoord = await getGeoCoordinates(`${user.address_detail.l4} ${user.address_detail.l6}`)
- logger.info(`${user.establishment_siret} - geocoord: ${geocoord} - adresse: ${user.address_detail.l4} ${user.address_detail.l6} `)
- } else {
- // else API address V3
- geocoord = await getGeoCoordinates(`${user.address_detail?.acheminement_postal?.l4} ${user.address_detail?.acheminement_postal?.l6}`)
- logger.info(`${user.establishment_siret} - geocoord: ${geocoord} - adresse: ${user.address_detail?.acheminement_postal?.l4} ${user.address_detail?.acheminement_postal?.l6} `)
- }
- user.geo_coordinates = geocoord ? `${geocoord.latitude},${geocoord.longitude}` : null
- await user.save()
- })
-}
-
-const recoverMissingGeocoordinatesRecruiters = async () => {
- const recruiters = await Recruiter.find({ geo_coordinates: "NOT FOUND" })
-
- await asyncForEach(recruiters, async (recruiter) => {
- if (!recruiter.address_detail) return
- await sleep(500)
-
- let geocoord: GeoCoord | null
- if (recruiter.address_detail.l4) {
- // if address data is in API address V2
- geocoord = await getGeoCoordinates(`${recruiter.address_detail.l4} ${recruiter.address_detail.l6}`)
- } else {
- // else API address V3
- geocoord = await getGeoCoordinates(`${recruiter.address_detail.acheminement_postal.l4} ${recruiter.address_detail.acheminement_postal.l6}`)
- }
- logger.info(
- `${recruiter.establishment_siret} - geocoord: ${geocoord} - adresse: ${recruiter.address_detail.acheminement_postal.l4} ${recruiter.address_detail.acheminement_postal.l6} `
- )
- recruiter.geo_coordinates = geocoord ? `${geocoord.latitude},${geocoord.longitude}` : null
- await recruiter.save()
- })
-}
-
-export const recoverMissingGeocoordinates = async () => {
- await recoverMissingGeocoordinatesRecruiters()
- await recoverMissingGeocoordinatesUserRecruteur()
-}
diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/updateAddressDetailOnUserrecrutersCollection.ts b/server/src/jobs/lba_recruteur/formulaire/misc/updateAddressDetailOnUserrecrutersCollection.ts
deleted file mode 100644
index c255b49a70..0000000000
--- a/server/src/jobs/lba_recruteur/formulaire/misc/updateAddressDetailOnUserrecrutersCollection.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import Boom from "boom"
-
-import { logger } from "../../../../common/logger"
-import { Recruiter, UserRecruteur } from "../../../../common/model/index"
-import { asyncForEach, delay } from "../../../../common/utils/asyncUtils"
-import { CFA, ENTREPRISE } from "../../../../services/constant.service"
-import { getEtablissementFromGouv } from "../../../../services/etablissement.service"
-
-export const updateAddressDetailOnUserrecrutersCollection = async () => {
- logger.info("Start update user adresse detail")
- const users = await UserRecruteur.find({ type: { $in: [ENTREPRISE, CFA] }, address_detail: null })
-
- logger.info(`${users.length} entries to update...`)
-
- if (!users.length) return
-
- await asyncForEach(users, async (user, index) => {
- console.log(`${index}/${users.length} - ${user.type} - ${user.establishment_siret} - ${user._id}`)
-
- try {
- await delay(500)
- const { establishment_siret } = user
- if (!establishment_siret) {
- throw Boom.internal("unexpected: no establishment_siret on userRecruteur", { userId: user._id })
- }
- const etablissement = await getEtablissementFromGouv(establishment_siret)
-
- if (!etablissement) return
-
- user.address_detail = etablissement.data.adresse
-
- if (user.type !== ENTREPRISE) {
- await user.save()
- return
- }
-
- const { establishment_id } = user
- if (!establishment_id) {
- throw Boom.internal("unexpected: no establishment_id on userRecruteur of type ENTREPRISE", { userId: user._id })
- }
- const formulaire = await Recruiter.findOne({ establishment_id })
-
- if (!formulaire) {
- return
- }
-
- formulaire.address_detail = formulaire ? etablissement.data.adresse : undefined
-
- await Promise.all([user.save(), formulaire.save()])
- } catch (error: any) {
- const { errors } = error.response.data
-
- if (errors.length) {
- if (
- errors.includes("Le numéro de siret n'est pas correctement formatté") ||
- errors.includes("Le siret ou siren indiqué n'existe pas, n'est pas connu ou ne comporte aucune information pour cet appel")
- ) {
- console.log(`Invalid siret DELETED : ${user.establishment_siret} - User & Formulaire removed`)
- const { establishment_id } = user
- await UserRecruteur.findByIdAndDelete(user._id)
- if (establishment_id) {
- await Recruiter.findOneAndRemove({ establishment_id })
- }
- return
- }
- } else {
- console.log(error.response)
- }
- }
- })
- logger.info("End update user adresse detail")
-}
diff --git a/server/src/jobs/lba_recruteur/formulaire/relanceFormulaire.ts b/server/src/jobs/lba_recruteur/formulaire/relanceFormulaire.ts
index 783971f824..1e85b4c7d2 100644
--- a/server/src/jobs/lba_recruteur/formulaire/relanceFormulaire.ts
+++ b/server/src/jobs/lba_recruteur/formulaire/relanceFormulaire.ts
@@ -1,10 +1,13 @@
+import Boom from "boom"
import { groupBy } from "lodash-es"
import { JOB_STATUS } from "shared/models"
import { getStaticFilePath } from "@/common/utils/getStaticFilePath"
+import { sentryCaptureException } from "@/common/utils/sentryUtils"
+import { user2ToUserForToken } from "@/security/accessTokenService"
import { logger } from "../../../common/logger"
-import { Recruiter, UserRecruteur } from "../../../common/model/index"
+import { Recruiter, User2 } from "../../../common/model/index"
import { asyncForEach } from "../../../common/utils/asyncUtils"
import { notifyToSlack } from "../../../common/utils/slackUtils"
import config from "../../../config"
@@ -45,38 +48,47 @@ export const relanceFormulaire = async (threshold: number /* number of days to e
await asyncForEach(Object.values(groupByRecruiterOffres), async (jobsWithRecruiter) => {
const recruiter = jobsWithRecruiter[0].recruiter
- const { establishment_raison_sociale, establishment_id, is_delegated, cfa_delegated_siret } = recruiter
- const contactEntreprise = await UserRecruteur.findOne({ establishment_id }).lean()
- let contactCFA
- // get CFA informations if formulaire is handled by a CFA
- if (is_delegated && cfa_delegated_siret) {
- contactCFA = await UserRecruteur.findOne({ establishment_siret: cfa_delegated_siret })
- }
+ const { establishment_raison_sociale, is_delegated } = recruiter
+ try {
+ const { managed_by } = recruiter.jobs[0]
+ if (!managed_by) {
+ throw Boom.internal(`inattendu : managed_by manquant pour le formulaire id=${recruiter._id}`)
+ }
+ const contactUser = await User2.findOne({ _id: managed_by }).lean()
+ if (!contactUser) {
+ throw Boom.internal(`inattendu : impossible de trouver l'utilisateur gérant le formulaire id=${recruiter._id}`)
+ }
- await mailer.sendEmail({
- to: contactCFA?.email ?? contactEntreprise?.email,
- subject: "Vos offres expirent bientôt",
- template: getStaticFilePath("./templates/mail-expiration-offres.mjml.ejs"),
- data: {
- images: {
- logoLba: `${config.publicUrl}/images/emails/logo_LBA.png?raw=true`,
- logoFooter: `${config.publicUrl}/assets/logo-republique-francaise.png?raw=true`,
+ await mailer.sendEmail({
+ to: contactUser.email,
+ subject: "Vos offres expirent bientôt",
+ template: getStaticFilePath("./templates/mail-expiration-offres.mjml.ejs"),
+ data: {
+ images: {
+ logoLba: `${config.publicUrl}/images/emails/logo_LBA.png?raw=true`,
+ logoFooter: `${config.publicUrl}/assets/logo-republique-francaise.png?raw=true`,
+ },
+ last_name: sanitizeForEmail(contactUser.last_name),
+ first_name: sanitizeForEmail(contactUser.first_name),
+ establishment_raison_sociale,
+ is_delegated,
+ offres: jobsWithRecruiter.map((job) => ({
+ rome_appellation_label: job.rome_appellation_label ?? job.rome_label,
+ job_type: job.job_type,
+ job_level_label: job.job_level_label,
+ job_start_date: dayjs(job.job_start_date).format("DD/MM/YYYY"),
+ supprimer: createCancelJobLink(user2ToUserForToken(contactUser), job._id.toString()),
+ pourvue: createProvidedJobLink(user2ToUserForToken(contactUser), job._id.toString()),
+ })),
+ threshold,
+ url: `${config.publicUrl}/espace-pro/authentification`,
},
- last_name: sanitizeForEmail(contactCFA?.last_name ?? contactEntreprise?.last_name),
- first_name: sanitizeForEmail(contactCFA?.first_name ?? contactEntreprise?.first_name),
- establishment_raison_sociale,
- is_delegated,
- offres: jobsWithRecruiter.map((job) => ({
- rome_appellation_label: job.rome_appellation_label ?? job.rome_label,
- job_type: job.job_type,
- job_level_label: job.job_level_label,
- job_start_date: dayjs(job.job_start_date).format("DD/MM/YYYY"),
- supprimer: createCancelJobLink(contactCFA ?? contactEntreprise, job._id.toString()),
- pourvue: createProvidedJobLink(contactCFA ?? contactEntreprise, job._id.toString()),
- })),
- threshold,
- url: `${config.publicUrl}/espace-pro/authentification`,
- },
- })
+ })
+ } catch (err) {
+ const errorMessage = (err && typeof err === "object" && "message" in err && err.message) || err
+ logger.error(err)
+ logger.error(`Script de relance formulaire: recruiter id=${recruiter._id}, erreur: ${errorMessage}`)
+ sentryCaptureException(err)
+ }
})
}
diff --git a/server/src/jobs/lba_recruteur/opco/relanceOpco.ts b/server/src/jobs/lba_recruteur/opco/relanceOpco.ts
index fd4a42ca16..1b5983d99e 100644
--- a/server/src/jobs/lba_recruteur/opco/relanceOpco.ts
+++ b/server/src/jobs/lba_recruteur/opco/relanceOpco.ts
@@ -1,9 +1,10 @@
-import { IUserRecruteur } from "shared"
-import { ETAT_UTILISATEUR } from "shared/constants/recruteur"
+import { isEnum } from "shared"
+import { OPCOS } from "shared/constants/recruteur"
+import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model"
import { getStaticFilePath } from "@/common/utils/getStaticFilePath"
-import { UserRecruteur } from "../../../common/model/index"
+import { Entreprise, RoleManagement, User2 } from "../../../common/model/index"
import { asyncForEach } from "../../../common/utils/asyncUtils"
import config from "../../../config"
import mailer from "../../../services/mailer.service"
@@ -13,42 +14,49 @@ import mailer from "../../../services/mailer.service"
* @returns {}
*/
export const relanceOpco = async () => {
- const userAwaitingValidation = await UserRecruteur.find({
- $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.ATTENTE] },
- opco: { $nin: [null, "Opco multiple", "inconnu"] },
- }).lean()
+ const rolesAwaitingValidation = await RoleManagement.find(
+ {
+ $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, AccessStatus.AWAITING_VALIDATION] },
+ authorized_type: AccessEntityType.ENTREPRISE,
+ },
+ { authorized_id: 1 }
+ ).lean()
// Cancel the job if there's no users awaiting validation
- if (!userAwaitingValidation.length) return
+ if (!rolesAwaitingValidation.length) return
- // count user to validate per opco
- const userList = userAwaitingValidation.reduce>((acc, user) => {
- if (user.opco) {
- if (user.opco in acc) {
- acc[user.opco]++
- } else {
- acc[user.opco] = 1
+ const entreprises = await Entreprise.find({ _id: { $in: rolesAwaitingValidation.map(({ authorized_id }) => authorized_id) } })
+ const opcoCounts = entreprises.reduce>(
+ (acc, entreprise) => {
+ const { opco } = entreprise
+ if (!isEnum(OPCOS, opco)) {
+ return acc
}
- }
- return acc
- }, {})
+ const oldCount = acc[opco] ?? 0
+ acc[opco] = oldCount + 1
+ return acc
+ },
+ {} as Record
+ )
+ await Promise.all(
+ Object.entries(opcoCounts).map(async ([opco, count]) => {
+ // Get related user to send the email
+ const roles = await RoleManagement.find({ authorized_type: AccessEntityType.OPCO, authorized_id: opco }).lean()
+ const users = await User2.find({ _id: { $in: roles.map((role) => role.user_id) } })
- for (const opco in userList) {
- // Get related user to send the email
- const users = await UserRecruteur.find({ scope: opco, type: "OPCO" })
-
- await asyncForEach(users, async (user: IUserRecruteur) => {
- await mailer.sendEmail({
- to: user.email,
- subject: "Nouveaux comptes entreprises à valider",
- template: getStaticFilePath("./templates/mail-relance-opco.mjml.ejs"),
- data: {
- images: {
- logoLba: `${config.publicUrl}/images/emails/logo_LBA.png?raw=true`,
+ await asyncForEach(users, async (user) => {
+ await mailer.sendEmail({
+ to: user.email,
+ subject: "Nouveaux comptes entreprises à valider",
+ template: getStaticFilePath("./templates/mail-relance-opco.mjml.ejs"),
+ data: {
+ images: {
+ logoLba: `${config.publicUrl}/images/emails/logo_LBA.png?raw=true`,
+ },
+ count,
},
- count: userList[opco],
- },
+ })
})
})
- }
+ )
}
diff --git a/server/src/jobs/lba_recruteur/optout/sendMailToEtablissements.ts b/server/src/jobs/lba_recruteur/optout/sendMailToEtablissements.ts
deleted file mode 100644
index be1bade54f..0000000000
--- a/server/src/jobs/lba_recruteur/optout/sendMailToEtablissements.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import Joi from "joi"
-import { differenceBy } from "lodash-es"
-
-import { getStaticFilePath } from "@/common/utils/getStaticFilePath"
-import { createOptoutValidateMagicLink } from "@/services/appLinks.service"
-
-import { logger } from "../../../common/logger"
-import { Optout, UserRecruteur } from "../../../common/model/index"
-import { asyncForEach } from "../../../common/utils/asyncUtils"
-import config from "../../../config"
-import mailer from "../../../services/mailer.service"
-import { runScript } from "../../scriptWrapper"
-
-/**
- * @param {number} ms delay in millisecond
- */
-const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
-
-runScript(async () => {
- const [optOutList, users] = await Promise.all([Optout.find().lean(), UserRecruteur.find({ type: "CFA" }).lean()])
-
- const etablissementsToContact = differenceBy(optOutList, users, "siret")
-
- logger.info(`Sending optout mail to ${etablissementsToContact.length} etablissement`)
-
- await asyncForEach(etablissementsToContact, async (etablissement) => {
- // Filter contact that have already recieved an invitation from the contacts array
- const contact = etablissement.contacts.filter((contact) => {
- const found = etablissement.mail.find((y) => y.email === contact.email)
- if (!found) {
- return contact
- }
- })
-
- if (!contact.length) {
- logger.info(`Tous les contacts ont été solicité pour cet établissement : ${etablissement.siret}`)
- return
- }
-
- const { error, value: email } = Joi.string().email().validate(contact[0].email, { abortEarly: false })
-
- if (error) {
- await Optout.findByIdAndUpdate(etablissement._id, { $push: { mail: { email, messageId: "INVALIDE_EMAIL" } } })
- return
- }
-
- logger.info(`---- Sending mail for ${etablissement.siret} — ${email} ----`)
-
- let data
-
- try {
- data = await mailer.sendEmail({
- to: email,
- subject: "Vous êtes invité à rejoindre La bonne alternance",
- template: getStaticFilePath("./templates/mail-optout.mjml.ejs"),
- data: {
- images: {
- logoLba: `${config.publicUrl}/images/emails/logo_LBA.png?raw=true`,
- },
- raison_sociale: etablissement.raison_sociale,
- url: createOptoutValidateMagicLink(email, etablissement.siret),
- },
- })
- } catch (errror) {
- console.log(`ERROR : ${email} - ${etablissement.siret}`, "-----", error)
- return
- }
-
- await Optout.findByIdAndUpdate(etablissement._id, { $push: { mail: { email, messageId: data.messageId } } })
- logger.info(`${JSON.stringify(data)} — ${etablissement.siret} — ${email}`)
-
- await sleep(500)
- })
-})
diff --git a/server/src/jobs/lba_recruteur/user/misc/fillRecruiterRaisonSociale.ts b/server/src/jobs/lba_recruteur/user/misc/fillRecruiterRaisonSociale.ts
deleted file mode 100644
index 6b1b2b8f5d..0000000000
--- a/server/src/jobs/lba_recruteur/user/misc/fillRecruiterRaisonSociale.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import Boom from "boom"
-
-import { logger } from "../../../../common/logger"
-import { Recruiter, UserRecruteur } from "../../../../common/model/index"
-import { asyncForEach } from "../../../../common/utils/asyncUtils"
-import { sentryCaptureException } from "../../../../common/utils/sentryUtils"
-import { notifyToSlack } from "../../../../common/utils/slackUtils"
-import { formatEntrepriseData, getEtablissementFromGouv } from "../../../../services/etablissement.service"
-import { updateFormulaire } from "../../../../services/formulaire.service"
-import { updateUser } from "../../../../services/userRecruteur.service"
-
-const fillRecruiters = async () => {
- const recruiters = await Recruiter.find({
- establishment_raison_sociale: null,
- }).lean()
- const stats = { success: 0, failure: 0 }
- logger.info(`Remplissage des raisons sociales vides: ${recruiters.length} recruteurs à mettre à jour...`)
- await asyncForEach(recruiters, async (recruiter) => {
- const { establishment_siret, establishment_id } = recruiter
- try {
- const siretResponse = await getEtablissementFromGouv(establishment_siret)
- if (!siretResponse) {
- throw Boom.internal("Pas de réponse")
- }
- const { establishment_raison_sociale } = formatEntrepriseData(siretResponse.data)
- await updateFormulaire(establishment_id, { establishment_raison_sociale })
- stats.success++
- } catch (err) {
- sentryCaptureException(err)
- stats.failure++
- }
- })
- await notifyToSlack({
- subject: "Remplissage des raisons sociales - recruiters",
- message: `${stats.success} succès. ${stats.failure} erreurs.`,
- error: stats.failure > 0,
- })
- return stats
-}
-
-const fillUserRecruiters = async () => {
- const userRecruiters = await UserRecruteur.find({
- establishment_raison_sociale: null,
- }).lean()
- const stats = { success: 0, failure: 0 }
- logger.info(`Remplissage des raisons sociales vides: ${userRecruiters.length} user recruteurs à mettre à jour...`)
- await asyncForEach(userRecruiters, async (userRecruiter) => {
- const { establishment_siret } = userRecruiter
- try {
- if (!establishment_siret) {
- throw Boom.internal("Missing establishment_siret", { _id: userRecruiter._id })
- }
- const siretResponse = await getEtablissementFromGouv(establishment_siret)
- if (!siretResponse) {
- throw Boom.internal("Pas de réponse")
- }
- const { establishment_raison_sociale } = formatEntrepriseData(siretResponse.data)
- await updateUser({ _id: userRecruiter._id }, { establishment_raison_sociale })
- stats.success++
- } catch (err) {
- sentryCaptureException(err)
- stats.failure++
- }
- })
- await notifyToSlack({
- subject: "Remplissage des raisons sociales - user recruiters",
- message: `${stats.success} succès. ${stats.failure} erreurs.`,
- error: stats.failure > 0,
- })
- return stats
-}
-
-export const fillRecruiterRaisonSociale = async () => {
- await fillUserRecruiters()
- await fillRecruiters()
-}
diff --git a/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurCfaDataValidation.ts b/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurCfaDataValidation.ts
deleted file mode 100644
index 703e49177b..0000000000
--- a/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurCfaDataValidation.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import Boom from "boom"
-import { ZCfaReferentielData } from "shared/models"
-
-import { logger } from "@/common/logger"
-import { UserRecruteur } from "@/common/model"
-import { asyncForEach } from "@/common/utils/asyncUtils"
-import { sentryCaptureException } from "@/common/utils/sentryUtils"
-import { notifyToSlack } from "@/common/utils/slackUtils"
-import { getOrganismeDeFormationDataFromSiret } from "@/services/etablissement.service"
-import { updateUser } from "@/services/userRecruteur.service"
-
-export const fixUserRecruiterCfaDataValidation = async () => {
- const subject = "Fix data validations pour les userrecruteurs CFA : address_detail"
- const userRecruteurs = await UserRecruteur.find({ type: "CFA" }).lean()
- const stats = { success: 0, failure: 0, skip: 0 }
- logger.info(`${subject}: ${userRecruteurs.length} user recruteurs à mettre à jour...`)
- await asyncForEach(userRecruteurs, async (userRecruiter, index) => {
- try {
- index % 100 === 0 && logger.info("index", index)
- const { establishment_siret, is_qualiopi, establishment_raison_sociale, address_detail, address, geo_coordinates } = userRecruiter
- if (
- !ZCfaReferentielData.pick({
- is_qualiopi: true,
- establishment_siret: true,
- establishment_raison_sociale: true,
- address_detail: true,
- address: true,
- geo_coordinates: true,
- }).safeParse({
- is_qualiopi,
- establishment_siret,
- establishment_raison_sociale,
- address_detail,
- address,
- geo_coordinates,
- }).success
- ) {
- if (!establishment_siret) {
- throw Boom.internal("Missing establishment_siret", { _id: userRecruiter._id })
- }
- const cfaData = await getOrganismeDeFormationDataFromSiret(establishment_siret, false)
- await updateUser({ _id: userRecruiter._id }, cfaData)
- stats.success++
- } else {
- stats.skip++
- }
- } catch (err) {
- logger.error(err)
- sentryCaptureException(err)
- stats.failure++
- }
- })
- await notifyToSlack({
- subject,
- message: `${stats.failure} erreurs. ${stats.success} mises à jour. ${stats.skip} ignorés.`,
- error: stats.failure > 0,
- })
- return stats
-}
diff --git a/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurDataValidation.ts b/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurDataValidation.ts
deleted file mode 100644
index 82375ea9d0..0000000000
--- a/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurDataValidation.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import Boom from "boom"
-import { ZGlobalAddress } from "shared/models"
-
-import { logger } from "@/common/logger"
-import { UserRecruteur } from "@/common/model"
-import { asyncForEach } from "@/common/utils/asyncUtils"
-import { sentryCaptureException } from "@/common/utils/sentryUtils"
-import { notifyToSlack } from "@/common/utils/slackUtils"
-import { formatEntrepriseData, getEtablissementFromGouv, getGeoCoordinates } from "@/services/etablissement.service"
-import { updateUser } from "@/services/userRecruteur.service"
-
-const fixAddressDetailAcademie = async () => {
- const subject = "Fix data validations pour userrecruteurs : address_detail.academie & address_detail.l1"
- const userRecruteurs = await UserRecruteur.find({
- $or: [
- {
- "address_detail.academie": { $exists: true },
- },
- {
- "address_detail.l1": { $exists: true },
- },
- ],
- type: "ENTREPRISE",
- }).lean()
- const stats = { success: 0, failure: 0 }
- logger.info(`${subject}: ${userRecruteurs.length} user recruteurs à mettre à jour...`)
- await asyncForEach(userRecruteurs, async (userRecruiter, index) => {
- try {
- index % 100 === 0 && logger.info("index", index)
- const { address_detail, establishment_siret } = userRecruiter
- if (address_detail && ("academie" in address_detail || "l1" in address_detail) && !ZGlobalAddress.safeParse(address_detail).success) {
- if (!establishment_siret) {
- throw Boom.internal("Missing establishment_siret", { _id: userRecruiter._id })
- }
- const siretResponse = await getEtablissementFromGouv(establishment_siret)
- if (!siretResponse) {
- throw Boom.internal("Pas de réponse")
- }
- const entrepriseData = formatEntrepriseData(siretResponse.data)
- const numeroEtRue = entrepriseData.address_detail.acheminement_postal.l4
- const codePostalEtVille = entrepriseData.address_detail.acheminement_postal.l6
- const { latitude, longitude } = await getGeoCoordinates(`${numeroEtRue}, ${codePostalEtVille}`).catch(() => getGeoCoordinates(codePostalEtVille))
- const savedData = { ...entrepriseData, geo_coordinates: `${latitude},${longitude}` }
- await updateUser({ _id: userRecruiter._id }, savedData)
- }
- stats.success++
- } catch (err) {
- sentryCaptureException(err)
- stats.failure++
- }
- })
- await notifyToSlack({
- subject,
- message: `${stats.failure} erreurs. ${stats.success} mises à jour`,
- error: stats.failure > 0,
- })
- return stats
-}
-
-export const fixUserRecruiterDataValidation = async () => {
- await fixAddressDetailAcademie()
-}
diff --git a/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts b/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts
deleted file mode 100644
index 13ac04db6b..0000000000
--- a/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import Boom from "boom"
-import { ETAT_UTILISATEUR } from "shared/constants/recruteur"
-
-import { logger } from "../../../../common/logger"
-import { UserRecruteur } from "../../../../common/model/index"
-import { asyncForEach } from "../../../../common/utils/asyncUtils"
-import { notifyToSlack } from "../../../../common/utils/slackUtils"
-import { ENTREPRISE } from "../../../../services/constant.service"
-import { autoValidateCompany } from "../../../../services/etablissement.service"
-import { activateEntrepriseRecruiterForTheFirstTime, getFormulaire } from "../../../../services/formulaire.service"
-import { sendWelcomeEmailToUserRecruteur, updateUser } from "../../../../services/userRecruteur.service"
-
-export const checkAwaitingCompaniesValidation = async () => {
- logger.info(`Start update missing validation state for companies...`)
- const stat = { validated: 0, notFound: 0, total: 0 }
-
- const entreprises = await UserRecruteur.find({
- $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.ATTENTE] },
- type: ENTREPRISE,
- })
-
- if (!entreprises.length) {
- await notifyToSlack({ subject: "USER VALIDATION", message: "Aucunes entreprises à contrôler" })
- return
- }
-
- stat.total = entreprises.length
-
- logger.info(`${entreprises.length} etp à mettre à jour...`)
-
- await asyncForEach(entreprises, async (entreprise) => {
- const { establishment_id } = entreprise
- if (!establishment_id) {
- throw Boom.internal("unexpected: no establishment_id for userRecruteur of type ENTREPRISE", { userId: entreprise._id })
- }
- const userFormulaire = await getFormulaire({ establishment_id })
-
- if (!userFormulaire) {
- await UserRecruteur.findByIdAndDelete(entreprise.establishment_id)
- return
- }
-
- const { validated: hasBeenValidated } = await autoValidateCompany(entreprise)
- if (hasBeenValidated) {
- stat.validated++
- } else {
- stat.notFound++
- }
-
- const firstJob = userFormulaire.jobs.at(0)
- if (hasBeenValidated && firstJob) {
- await activateEntrepriseRecruiterForTheFirstTime(userFormulaire)
-
- // Validate user email addresse
- await updateUser({ _id: entreprise._id }, { is_email_checked: true })
- await sendWelcomeEmailToUserRecruteur(entreprise)
- }
- })
-
- await notifyToSlack({
- subject: "USER VALIDATION",
- message: `${stat.validated} entreprises validées sur un total de ${stat.total} (${stat.notFound} reste à valider manuellement)`,
- })
-
- logger.info(`Done.`)
- return stat
-}
diff --git a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts
index ad5f105f2c..e777c7b37a 100644
--- a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts
+++ b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts
@@ -1,56 +1,74 @@
import Boom from "boom"
-import { JOB_STATUS, type IUserRecruteur } from "shared"
-import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur"
+import { JOB_STATUS } from "shared"
+import { CFA, RECRUITER_STATUS } from "shared/constants/recruteur"
+import { EntrepriseStatus } from "shared/models/entreprise.model"
+import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
+
+import { getUser2ManagingOffer } from "@/services/application.service"
import { logger } from "../../../../common/logger"
-import { Recruiter, UserRecruteur } from "../../../../common/model/index"
+import { Cfa, Entreprise, Recruiter, RoleManagement, User2 } from "../../../../common/model/index"
import { asyncForEach } from "../../../../common/utils/asyncUtils"
import { sentryCaptureException } from "../../../../common/utils/sentryUtils"
import { notifyToSlack } from "../../../../common/utils/slackUtils"
-import { CFA, ENTREPRISE } from "../../../../services/constant.service"
-import { autoValidateCompany, EntrepriseData, getEntrepriseDataFromSiret, sendEmailConfirmationEntreprise } from "../../../../services/etablissement.service"
-import { activateEntrepriseRecruiterForTheFirstTime, archiveFormulaire, getFormulaire, sendMailNouvelleOffre, updateFormulaire } from "../../../../services/formulaire.service"
-import { autoValidateUser, deactivateUser, getUser, setUserInError, updateUser } from "../../../../services/userRecruteur.service"
+import { ENTREPRISE } from "../../../../services/constant.service"
+import { EntrepriseData, autoValidateUserRoleOnCompany, getEntrepriseDataFromSiret, sendEmailConfirmationEntreprise } from "../../../../services/etablissement.service"
+import { activateEntrepriseRecruiterForTheFirstTime, archiveFormulaire, sendMailNouvelleOffre, updateFormulaire } from "../../../../services/formulaire.service"
+import { UserAndOrganization, deactivateEntreprise, setEntrepriseInError } from "../../../../services/userRecruteur.service"
-const updateUserRecruteursSiretInfosInError = async () => {
- const userRecruteurs = await UserRecruteur.find({
- $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.ERROR] },
- $or: [{ type: CFA }, { type: ENTREPRISE }],
+const updateEntreprisesInfosInError = async () => {
+ const entreprises = await Entreprise.find({
+ $expr: { $in: [{ $arrayElemAt: ["$status.status", -1] }, [EntrepriseStatus.ERROR, EntrepriseStatus.A_METTRE_A_JOUR]] },
}).lean()
const stats = { success: 0, failure: 0, deactivated: 0 }
- logger.info(`Correction des user recruteurs en erreur: ${userRecruteurs.length} user recruteurs à mettre à jour...`)
- await asyncForEach(userRecruteurs, async (userRecruteur) => {
- const { establishment_siret, _id, establishment_id, type } = userRecruteur
+ logger.info(`Correction des entreprises en erreur: ${entreprises.length} entreprises à mettre à jour...`)
+ await asyncForEach(entreprises, async (entreprise) => {
+ const { siret, _id } = entreprise
try {
- if (!establishment_id || !establishment_siret) {
- throw Boom.internal("unexpected: no establishment_id and/or establishment_siret for userRecruteur of type ENTREPRISE", { userId: userRecruteur._id })
+ if (!siret) {
+ throw Boom.internal("unexpected: no siret for userRecruteur of type ENTREPRISE", { id: entreprise._id })
}
- let recruteur = await getFormulaire({ establishment_id })
- const { cfa_delegated_siret } = recruteur
- const siretResponse = await getEntrepriseDataFromSiret({ siret: establishment_siret, cfa_delegated_siret: cfa_delegated_siret ?? undefined })
+ const siretResponse = await getEntrepriseDataFromSiret({ siret, type: ENTREPRISE })
if ("error" in siretResponse) {
- logger.warn(`Correction des recruteurs en erreur: userRecruteur id=${_id}, désactivation car création interdite, raison=${siretResponse.message}`)
- await deactivateUser(_id, siretResponse.message)
+ logger.warn(`Correction des recruteurs en erreur: entreprise id=${_id}, désactivation car création interdite, raison=${siretResponse.message}`)
+ await deactivateEntreprise(_id, siretResponse.message)
stats.deactivated++
} else {
const entrepriseData: Partial = siretResponse
- let updatedUserRecruteur: IUserRecruteur = await updateUser({ _id }, entrepriseData)
- recruteur = await updateFormulaire(recruteur.establishment_id, entrepriseData)
- if (type === "ENTREPRISE") {
- const result = await autoValidateCompany(updatedUserRecruteur)
- updatedUserRecruteur = result.userRecruteur
- if (result.validated) {
- await activateEntrepriseRecruiterForTheFirstTime(recruteur)
- await sendEmailConfirmationEntreprise(updatedUserRecruteur, recruteur)
- }
- } else {
- updatedUserRecruteur = await autoValidateUser(userRecruteur._id)
+ const updatedEntreprise = await Entreprise.findOneAndUpdate({ _id }, entrepriseData, { new: true }).lean()
+ if (!updatedEntreprise) {
+ throw Boom.internal(`could not find and update entreprise with id=${_id}`)
}
+ await Recruiter.updateMany({ establishment_siret: siret }, entrepriseData)
+ const recruiters = await Recruiter.find({ establishment_siret: siret }).lean()
+ const roles = await RoleManagement.find({ authorized_type: AccessEntityType.ENTREPRISE, authorized_id: updatedEntreprise._id.toString() }).lean()
+ const rolesToUpdate = roles.filter((role) => getLastStatusEvent(role.status)?.status !== AccessStatus.DENIED)
+ const users = await User2.find({ _id: { $in: rolesToUpdate.map((role) => role.user_id) } }).lean()
+ await Promise.all(
+ users.map(async (user) => {
+ const userAndOrganization: UserAndOrganization = { user, type: ENTREPRISE, organization: updatedEntreprise }
+ const result = await autoValidateUserRoleOnCompany(userAndOrganization, "reprise des entreprises en erreur")
+ if (result.validated) {
+ const recruiter = recruiters.find((recruiter) => recruiter.email === user.email && recruiter.establishment_siret === siret)
+ if (!recruiter) {
+ throw Boom.internal(`inattendu : recruiter non trouvé`, { email: user.email, siret })
+ }
+ await activateEntrepriseRecruiterForTheFirstTime(recruiter)
+ const role = rolesToUpdate.find((role) => role.user_id.toString() === user._id.toString())
+ const status = getLastStatusEvent(role?.status)?.status
+ if (!status) {
+ throw Boom.internal("inattendu : status du role non trouvé")
+ }
+ await sendEmailConfirmationEntreprise(user, recruiter, status, EntrepriseStatus.VALIDE)
+ }
+ })
+ )
stats.success++
}
} catch (err) {
const errorMessage = (err && typeof err === "object" && "message" in err && err.message) || err
- await setUserInError(userRecruteur._id, errorMessage + "")
+ await setEntrepriseInError(entreprise._id, errorMessage + "")
logger.error(err)
logger.error(`Correction des recruteurs en erreur: userRecruteur id=${_id}, erreur: ${errorMessage}`)
sentryCaptureException(err)
@@ -76,7 +94,7 @@ const updateRecruteursSiretInfosInError = async () => {
return
}
try {
- const siretResponse = await getEntrepriseDataFromSiret({ siret: establishment_siret, cfa_delegated_siret })
+ const siretResponse = await getEntrepriseDataFromSiret({ siret: establishment_siret, type: CFA })
if ("error" in siretResponse) {
logger.warn(`Correction des recruteurs en erreur: recruteur id=${_id}, désactivation car création interdite, raison=${siretResponse.message}`)
await archiveFormulaire(establishment_id)
@@ -84,19 +102,26 @@ const updateRecruteursSiretInfosInError = async () => {
} else {
const entrepriseData: Partial = siretResponse
const updatedRecruiter = await updateFormulaire(establishment_id, { ...entrepriseData, status: RECRUITER_STATUS.ACTIF })
- const userRecruteurCFA = await getUser({ establishment_siret: cfa_delegated_siret, $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.VALIDE] } })
- if (!userRecruteurCFA) {
- throw Boom.internal(`unexpected: impossible de trouver le user recruteur CFA avec siret=${cfa_delegated_siret}`)
+ const managingUser = await getUser2ManagingOffer(updatedRecruiter.jobs[0])
+ const cfa = await Cfa.findOne({ siret: cfa_delegated_siret }).lean()
+ if (!cfa) {
+ throw Boom.internal(`could not find cfa with siret=${cfa_delegated_siret}`)
+ }
+ const role = await RoleManagement.findOne({ user_id: managingUser._id, authorized_type: AccessEntityType.CFA, authorized_id: cfa._id.toString() }).lean()
+ if (!role) {
+ throw Boom.internal(`could not find role with user_id=${managingUser._id} and authorized_id=${cfa._id}`)
+ }
+ if (getLastStatusEvent(role.status)?.status === AccessStatus.GRANTED) {
+ await Promise.all(
+ updatedRecruiter.jobs.flatMap((job) => {
+ if (job.job_status === JOB_STATUS.ACTIVE) {
+ return [sendMailNouvelleOffre(updatedRecruiter, job, managingUser)]
+ } else {
+ return []
+ }
+ })
+ )
}
- await Promise.all(
- updatedRecruiter.jobs.flatMap((job) => {
- if (job.job_status === JOB_STATUS.ACTIVE) {
- return [sendMailNouvelleOffre(updatedRecruiter, job, userRecruteurCFA)]
- } else {
- return []
- }
- })
- )
stats.success++
}
} catch (err) {
@@ -117,7 +142,7 @@ const updateRecruteursSiretInfosInError = async () => {
}
export const updateSiretInfosInError = async () => {
- const userRecruteurResult = await updateUserRecruteursSiretInfosInError()
+ const userRecruteurResult = await updateEntreprisesInfosInError()
const recruteurResult = await updateRecruteursSiretInfosInError()
return {
userRecruteurResult,
diff --git a/server/src/jobs/lbb/updateGeoLocations.ts b/server/src/jobs/lbb/updateGeoLocations.ts
index e75e6555ec..71f602d0d8 100644
--- a/server/src/jobs/lbb/updateGeoLocations.ts
+++ b/server/src/jobs/lbb/updateGeoLocations.ts
@@ -62,7 +62,7 @@ const saveGeoData = async (geoData) => {
try {
await geoLocation.save()
} catch (err) {
- console.log("error saving geoloc probably from duplicate restriction: ", geoLocation.address)
+ console.error("error saving geoloc probably from duplicate restriction: ", geoLocation.address)
}
}
}
diff --git a/server/src/jobs/lbb/updateLbaCompanies.ts b/server/src/jobs/lbb/updateLbaCompanies.ts
index a9e3c3e7bd..044f0617ef 100644
--- a/server/src/jobs/lbb/updateLbaCompanies.ts
+++ b/server/src/jobs/lbb/updateLbaCompanies.ts
@@ -87,7 +87,7 @@ export default async function updateLbaCompanies({
try {
logMessage("info", " -- Start updating lbb db with new algo -- ")
- console.log("UseAlgoFile : ", UseAlgoFile, " - ClearMongo : ", ClearMongo, " - UseSave : ", UseSave, " - ForceRecreate : ", ForceRecreate)
+ console.info("UseAlgoFile : ", UseAlgoFile, " - ClearMongo : ", ClearMongo, " - UseSave : ", UseSave, " - ForceRecreate : ", ForceRecreate)
if (UseAlgoFile) {
if (!ForceRecreate) {
diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts
new file mode 100644
index 0000000000..107f098121
--- /dev/null
+++ b/server/src/jobs/multiCompte/migrationUsers.ts
@@ -0,0 +1,412 @@
+import dayjs from "dayjs"
+import { getLastStatusEvent, IRecruiter, parseEnumOrError, ZGlobalAddress } from "shared"
+import { ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js"
+import { ICFA } from "shared/models/cfa.model.js"
+import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent } from "shared/models/entreprise.model.js"
+import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model.js"
+import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model.js"
+import { IUserRecruteur } from "shared/models/usersRecruteur.model.js"
+
+import { ObjectId } from "@/common/mongodb.js"
+
+import { logger } from "../../common/logger.js"
+import { Recruiter, UserRecruteur } from "../../common/model/index.js"
+import { Cfa } from "../../common/model/schema/multiCompte/cfa.schema.js"
+import { Entreprise } from "../../common/model/schema/multiCompte/entreprise.schema.js"
+import { RoleManagement } from "../../common/model/schema/multiCompte/roleManagement.schema.js"
+import { User2 } from "../../common/model/schema/multiCompte/user2.schema.js"
+import { notifyToSlack } from "../../common/utils/slackUtils.js"
+
+export const migrationUsers = async () => {
+ await User2.deleteMany({})
+ await Entreprise.deleteMany({})
+ await Cfa.deleteMany({})
+ await RoleManagement.deleteMany({})
+ await migrationRecruiters()
+ await migrationUserRecruteurs()
+}
+
+const migrationRecruiters = async () => {
+ logger.info(`Migration: lecture des recruiteurs...`)
+ const stats = { success: 0, failure: 0, jobSuccess: 0 }
+ const recruiterOrphans: string[] = []
+
+ await cursorForEach(await Recruiter.find({}).lean(), async (recruiter, index) => {
+ index % 1000 === 0 && logger.info(`import du recruiteur n°${index}`)
+ try {
+ const { establishment_id, cfa_delegated_siret, jobs } = recruiter
+ let userRecruiter: IUserRecruteur
+ if (cfa_delegated_siret) {
+ userRecruiter = await UserRecruteur.findOne({ establishment_siret: cfa_delegated_siret }).lean()
+ if (!userRecruiter) {
+ throw new Error(`inattendu: impossible de trouver le user recruteur avec establishment_siret=${cfa_delegated_siret}`)
+ }
+ } else {
+ userRecruiter = await UserRecruteur.findOne({ establishment_id }).lean()
+ if (!userRecruiter) {
+ recruiterOrphans.push(establishment_id)
+ throw new Error(`inattendu: impossible de trouver le user recruteur avec establishment_id=${establishment_id}`)
+ }
+ }
+ await Recruiter.findOneAndUpdate({ _id: recruiter._id }, { managed_by: userRecruiter._id })
+ await Promise.all(
+ jobs.map(async (job) => {
+ await Recruiter.findOneAndUpdate(
+ { "jobs._id": job._id },
+ {
+ $set: {
+ // les ids des users sont identiques aux userRecruteurs. Les userRecruteurs sont migrés après pour écraser les infos de contact
+ "jobs.$.managed_by": userRecruiter._id,
+ },
+ },
+ { new: true }
+ ).lean()
+ stats.jobSuccess++
+ })
+ )
+ stats.success++
+ } catch (err) {
+ logger.error(`erreur lors de l'import du recruiteur avec id=${recruiter._id}`)
+ logger.error(err)
+ stats.failure++
+ }
+ })
+ logger.info(`recruiters orphelins :
+ ${JSON.stringify(recruiterOrphans, null, 2)}
+ `)
+ logger.info(`Migration: user candidats terminés`)
+ const message = `${stats.success} recruiteurs repris avec succès.
+ ${stats.failure} recruiteurs en erreur.
+ ${stats.jobSuccess} offres reprises avec succès.
+ `
+ logger.info(message)
+ await notifyToSlack({
+ subject: "Migration multi-compte",
+ message,
+ error: stats.failure > 0,
+ })
+ return stats
+}
+
+const migrationUserRecruteurs = async () => {
+ logger.info(`Migration: lecture des user recruteurs...`)
+ const stats = { success: 0, failure: 0, entrepriseCreated: 0, cfaCreated: 0, userCreated: 0, adminAccess: 0, opcoAccess: 0 }
+ await cursorForEach((await UserRecruteur.find({}).lean()).reverse(), async (userRecruteur, index) => {
+ const {
+ last_name,
+ first_name,
+ opco,
+ idcc,
+ establishment_raison_sociale,
+ establishment_enseigne,
+ establishment_siret,
+ address,
+ geo_coordinates,
+ phone,
+ email,
+ scope,
+ type,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ establishment_id,
+ origin: originRaw,
+ is_email_checked,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ is_qualiopi,
+ last_connection,
+ createdAt,
+ updatedAt,
+ } = userRecruteur
+
+ const oldStatus: IUserRecruteur["status"] | undefined = userRecruteur.status
+ const origin = originRaw || "user migration"
+ index % 1000 === 0 && logger.info(`import du user recruteur n°${index}`)
+ try {
+ const newStatus: IUserStatusEvent[] = []
+ const lastOldStatus = getLastStatusEvent(oldStatus)?.status
+ if (is_email_checked) {
+ newStatus.push({
+ date: createdAt,
+ reason: "migration",
+ status: UserEventType.VALIDATION_EMAIL,
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ granted_by: "migration",
+ })
+ }
+ newStatus.push({
+ date: createdAt,
+ reason: "migration",
+ status: lastOldStatus === ETAT_UTILISATEUR.DESACTIVE ? UserEventType.DESACTIVE : UserEventType.ACTIF,
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ granted_by: "migration",
+ })
+ const newUser: IUser2 = {
+ _id: userRecruteur._id,
+ first_name: first_name ?? "",
+ last_name: last_name ?? "",
+ phone: phone ?? "",
+ email,
+ last_action_date: last_connection,
+ createdAt,
+ updatedAt,
+ origin,
+ status: newStatus,
+ }
+ await createWithTimestamps(User2, newUser)
+ stats.userCreated++
+ if (type === ENTREPRISE) {
+ if (!establishment_siret) {
+ throw new Error("inattendu pour une ENTREPRISE: pas de establishment_siret")
+ }
+ const address_detail = fixAddressDetail(userRecruteur.address_detail)
+ checkAddressDetail(address_detail)
+ if (address_detail !== userRecruteur.address_detail && userRecruteur.establishment_id) {
+ const updatedRecruiter = await Recruiter.findOneAndUpdate({ establishment_id: userRecruteur.establishment_id }, { address_detail })
+ if (!updatedRecruiter) {
+ throw new Error("impossible de corriger address_detail : recruiter introuvable")
+ }
+ }
+ const newEntreprise: IEntreprise = {
+ _id: new ObjectId(),
+ origin,
+ siret: establishment_siret,
+ address,
+ address_detail,
+ enseigne: establishment_enseigne,
+ raison_sociale: establishment_raison_sociale,
+ geo_coordinates,
+ idcc,
+ opco,
+ createdAt,
+ updatedAt,
+ status: userRecruteurStatusToEntrepriseStatus(oldStatus),
+ }
+ let entreprise = await Entreprise.findOne({ siret: newEntreprise.siret }).lean()
+ if (entreprise) {
+ if (dayjs(entreprise.updatedAt).isBefore(updatedAt)) {
+ await Entreprise.findOneAndUpdate({ _id: entreprise._id }, { $set: { updatedAt } }, { timestamps: false })
+ }
+ if (dayjs(entreprise.createdAt).isAfter(createdAt)) {
+ await Entreprise.findOneAndUpdate({ _id: entreprise._id }, { $set: { createdAt } }, { timestamps: false })
+ }
+ } else {
+ if (getLastStatusEvent(newEntreprise.status)?.status !== EntrepriseStatus.ERROR) {
+ if (!newEntreprise.address || !newEntreprise.address_detail || !newEntreprise.enseigne || !newEntreprise.raison_sociale || !newEntreprise.geo_coordinates) {
+ newEntreprise.status.push({
+ date: new Date(),
+ reason: "champ manquant",
+ status: EntrepriseStatus.A_METTRE_A_JOUR,
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ granted_by: "migration",
+ })
+ }
+ }
+ entreprise = await createWithTimestamps(Entreprise, newEntreprise)
+ stats.entrepriseCreated++
+ }
+ const roleManagement: Omit = {
+ user_id: newUser._id,
+ authorized_type: AccessEntityType.ENTREPRISE,
+ authorized_id: entreprise._id.toString(),
+ createdAt: userRecruteur.createdAt,
+ updatedAt: userRecruteur.updatedAt,
+ origin,
+ status: userRecruteurStatusToRoleManagementStatus(oldStatus),
+ }
+ await createWithTimestamps(RoleManagement, roleManagement)
+ } else if (type === "CFA") {
+ if (!establishment_siret) {
+ throw new Error("inattendu pour un CFA: pas de establishment_siret")
+ }
+ const address_detail = fixAddressDetail(userRecruteur.address_detail)
+ checkAddressDetail(address_detail)
+ const newCfa: ICFA = {
+ _id: new ObjectId(),
+ siret: establishment_siret,
+ address,
+ address_detail,
+ enseigne: establishment_enseigne,
+ raison_sociale: establishment_raison_sociale,
+ geo_coordinates,
+ origin,
+ createdAt,
+ updatedAt,
+ }
+ let cfa = await Cfa.findOne({ siret: newCfa.siret }).lean()
+ if (cfa) {
+ if (dayjs(cfa.updatedAt).isBefore(updatedAt)) {
+ await Cfa.findOneAndUpdate({ _id: cfa._id }, { $set: { updatedAt } }, { timestamps: false })
+ }
+ if (dayjs(cfa.createdAt).isAfter(createdAt)) {
+ await Cfa.findOneAndUpdate({ _id: cfa._id }, { $set: { createdAt } }, { timestamps: false })
+ }
+ } else {
+ cfa = await createWithTimestamps(Cfa, newCfa)
+ stats.cfaCreated++
+ }
+ const roleManagement: Omit = {
+ user_id: newUser._id,
+ authorized_type: AccessEntityType.CFA,
+ authorized_id: cfa._id.toString(),
+ createdAt: userRecruteur.createdAt,
+ updatedAt: userRecruteur.updatedAt,
+ origin,
+ status: userRecruteurStatusToRoleManagementStatus(oldStatus),
+ }
+ await createWithTimestamps(RoleManagement, roleManagement)
+ } else if (type === "ADMIN") {
+ const roleManagement: Omit = {
+ user_id: newUser._id,
+ authorized_type: AccessEntityType.ADMIN,
+ authorized_id: "",
+ createdAt: userRecruteur.createdAt,
+ updatedAt: userRecruteur.updatedAt,
+ origin,
+ status: userRecruteurStatusToRoleManagementStatus(oldStatus),
+ }
+ await createWithTimestamps(RoleManagement, roleManagement)
+ stats.adminAccess++
+ } else if (type === "OPCO") {
+ const opco = parseEnumOrError(OPCOS, scope ?? null)
+ const roleManagement: Omit = {
+ user_id: newUser._id,
+ authorized_type: AccessEntityType.OPCO,
+ authorized_id: opco,
+ createdAt: userRecruteur.createdAt,
+ updatedAt: userRecruteur.updatedAt,
+ origin,
+ status: userRecruteurStatusToRoleManagementStatus(oldStatus),
+ }
+ await createWithTimestamps(RoleManagement, roleManagement)
+ stats.opcoAccess++
+ } else {
+ throw new Error(`unsupported type: ${type}`)
+ }
+ stats.success++
+ } catch (err) {
+ logger.error(`erreur lors de l'import du user recruteur avec id=${userRecruteur._id}`)
+ logger.error(err)
+ stats.failure++
+ }
+ })
+ logger.info(`Migration: user candidats terminés`)
+ const message = `${stats.success} user recruteurs repris avec succès.
+ ${stats.failure} user recruteurs en erreur.
+ ${stats.userCreated} user créés.
+ ${stats.entrepriseCreated} entreprises créées.
+ ${stats.cfaCreated} CFA créés.
+ `
+ logger.info(message)
+ await notifyToSlack({
+ subject: "Migration multi-compte",
+ message,
+ error: stats.failure > 0,
+ })
+ return stats
+}
+
+function userRecruteurStatusToRoleManagementStatus(allStatus: IUserRecruteur["status"] | undefined): IRoleManagementEvent[] {
+ const computedStatus = (allStatus ?? []).flatMap((statusEvent) => {
+ const { date, reason, status, user, validation_type } = statusEvent
+ const statusMapping: Record = {
+ [ETAT_UTILISATEUR.DESACTIVE]: AccessStatus.DENIED,
+ [ETAT_UTILISATEUR.VALIDE]: AccessStatus.GRANTED,
+ [ETAT_UTILISATEUR.ATTENTE]: AccessStatus.AWAITING_VALIDATION,
+ [ETAT_UTILISATEUR.ERROR]: AccessStatus.AWAITING_VALIDATION,
+ }
+ const accessStatus = status ? statusMapping[status] : null
+ if (accessStatus && date) {
+ const newEvent: IRoleManagementEvent = {
+ date,
+ reason: reason ?? "",
+ validation_type: parseEnumOrError(VALIDATION_UTILISATEUR, validation_type),
+ granted_by: user,
+ status: accessStatus,
+ }
+ return [newEvent]
+ } else {
+ return []
+ }
+ })
+ if (!computedStatus.length) {
+ return [
+ {
+ date: new Date(),
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ reason: "multi compte : aucun status",
+ status: AccessStatus.GRANTED,
+ },
+ ]
+ }
+ return computedStatus
+}
+
+function userRecruteurStatusToEntrepriseStatus(allStatus: IUserRecruteur["status"] | undefined): IEntrepriseStatusEvent[] {
+ const computedStatus = (allStatus ?? []).flatMap((statusEvent) => {
+ const { date, reason, status, user, validation_type } = statusEvent
+ const statusMapping: Record = {
+ [ETAT_UTILISATEUR.VALIDE]: EntrepriseStatus.VALIDE,
+ [ETAT_UTILISATEUR.ERROR]: EntrepriseStatus.ERROR,
+ [ETAT_UTILISATEUR.ATTENTE]: EntrepriseStatus.VALIDE,
+ [ETAT_UTILISATEUR.DESACTIVE]: null,
+ }
+ const entrepriseStatus = status ? statusMapping[status] : null
+ if (entrepriseStatus && date) {
+ const newEvent: IEntrepriseStatusEvent = {
+ date,
+ reason: reason ?? "",
+ validation_type: parseEnumOrError(VALIDATION_UTILISATEUR, validation_type),
+ granted_by: user,
+ status: entrepriseStatus,
+ }
+ return [newEvent]
+ } else {
+ return []
+ }
+ })
+ if (!computedStatus.length) {
+ return [
+ {
+ date: new Date(),
+ reason: "migration multi compte : aucun status présent",
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ status: EntrepriseStatus.ERROR,
+ },
+ ]
+ }
+ return computedStatus
+}
+
+const fixAddressDetail = (addressDetail: any) => {
+ const lFields = ["l1", "l2", "l3", "l4", "l5", "l6", "l7"]
+ const normalFields = ["numero_voie", "type_voie", "nom_voie", "complement_adresse", "code_postal", "localite", "code_insee_localite", "cedex"]
+ if (addressDetail && [...lFields, ...normalFields].every((field) => field in addressDetail)) {
+ return Object.fromEntries([
+ ...normalFields.map((field) => [field, addressDetail[field]]),
+ ["acheminement_postal", Object.fromEntries(lFields.map((field) => [field, addressDetail[field]]))],
+ ])
+ } else {
+ return addressDetail
+ }
+}
+
+const checkAddressDetail = (address_detail: any) => {
+ if (!address_detail) return
+ if (!ZGlobalAddress.safeParse(address_detail).success) {
+ throw new Error(`address_detail not ok: ${JSON.stringify(address_detail, null, 2)}`)
+ }
+}
+
+const cursorForEach = async (array: T[], fct: (item: T, index: number) => Promise) => {
+ let index = 0
+ let item: T | undefined = array.at(index)
+ while (item) {
+ await fct(item, index)
+ index++
+ item = array.at(index)
+ }
+}
+
+const createWithTimestamps = async (collection: any, document: T): Promise => {
+ const docs = await collection.create([document], { timestamps: false })
+ return docs[0]
+}
diff --git a/server/src/jobs/rdv/inviteEtablissementAffelnetToPremiumFollowUp.ts b/server/src/jobs/rdv/inviteEtablissementAffelnetToPremiumFollowUp.ts
index 0143eedc22..14f110ebe8 100644
--- a/server/src/jobs/rdv/inviteEtablissementAffelnetToPremiumFollowUp.ts
+++ b/server/src/jobs/rdv/inviteEtablissementAffelnetToPremiumFollowUp.ts
@@ -19,10 +19,15 @@ interface IEtablissementsToInviteToPremium {
count: number
}
-export const inviteEtablissementAffelnetToPremiumFollowUp = async () => {
+export const inviteEtablissementAffelnetToPremiumFollowUp = async (bypassDate: boolean = false) => {
logger.info("Cron #inviteEtablissementAffelnetToPremiumFollowUp started.")
let count = 0
+ let clause = [{ premium_affelnet_invitation_date: { $ne: null } }, { premium_affelnet_invitation_date: { $lte: dayjs().subtract(10, "days").toDate() } }]
+
+ if (bypassDate) {
+ clause = [{ premium_affelnet_invitation_date: { $ne: null } }]
+ }
const etablissementsToInviteToPremium: Array = await Etablissement.aggregate([
{
@@ -33,7 +38,7 @@ export const inviteEtablissementAffelnetToPremiumFollowUp = async () => {
premium_affelnet_activation_date: null,
premium_affelnet_refusal_date: null,
premium_affelnet_follow_up_date: null,
- $and: [{ premium_affelnet_invitation_date: { $ne: null } }, { premium_affelnet_invitation_date: { $lte: dayjs().subtract(10, "days").toDate() } }],
+ $and: clause,
},
},
{
diff --git a/server/src/jobs/rdv/inviteEtablissementParcoursupToPremiumFollowUp.ts b/server/src/jobs/rdv/inviteEtablissementParcoursupToPremiumFollowUp.ts
index 2751a6c7f0..b440e1f5d8 100644
--- a/server/src/jobs/rdv/inviteEtablissementParcoursupToPremiumFollowUp.ts
+++ b/server/src/jobs/rdv/inviteEtablissementParcoursupToPremiumFollowUp.ts
@@ -19,10 +19,15 @@ interface IEtablissementsToInviteToPremium {
count: number
}
-export const inviteEtablissementParcoursupToPremiumFollowUp = async () => {
+export const inviteEtablissementParcoursupToPremiumFollowUp = async (bypassDate: boolean = false) => {
logger.info("Cron #inviteEtablissementParcoursupToPremiumFollowUp started.")
let count = 0
+ let clause = [{ premium_invitation_date: { $ne: null } }, { premium_invitation_date: { $lte: dayjs().subtract(10, "days").toDate() } }]
+
+ if (bypassDate) {
+ clause = [{ premium_invitation_date: { $ne: null } }]
+ }
const etablissementsToInviteToPremium: Array = await Etablissement.aggregate([
{
@@ -33,7 +38,7 @@ export const inviteEtablissementParcoursupToPremiumFollowUp = async () => {
premium_activation_date: null,
premium_refusal_date: null,
premium_follow_up_date: null,
- $and: [{ premium_invitation_date: { $ne: null } }, { premium_invitation_date: { $lte: dayjs().subtract(10, "days").toDate() } }],
+ $and: clause,
},
},
{
diff --git a/server/src/security/accessLog.service.ts b/server/src/security/accessLog.service.ts
index a908dfd6b8..113435db80 100644
--- a/server/src/security/accessLog.service.ts
+++ b/server/src/security/accessLog.service.ts
@@ -25,13 +25,12 @@ export const createAccessLog = async
}
| { type: "lba-company"; siret: string; email: string }
| { type: "candidat"; email: string }
+ | IUser2ForAccessToken
scopes: ReadonlyArray>
}
+export type IUser2ForAccessToken = { type: "IUser2"; email: string; _id: string }
+
export type UserForAccessToken = IUserRecruteur | IAccessToken["identity"]
+export const user2ToUserForToken = (user: IUser2): IUser2ForAccessToken => ({ type: "IUser2", _id: user._id.toString(), email: user.email })
+
export function generateAccessToken(user: UserForAccessToken, scopes: ReadonlyArray>, options: { expiresIn?: string } = {}): string {
- const identity: IAccessToken["identity"] = "_id" in user ? { type: "IUserRecruteur", _id: user._id.toString(), email: user.email.toLowerCase() } : user
+ const identity: IAccessToken["identity"] = "_id" in user ? { type: "IUser2", _id: user._id.toString(), email: user.email.toLowerCase() } : user
const data: IAccessToken = {
identity,
scopes,
@@ -172,6 +178,7 @@ export function getAccessTokenScope(
)
}
+// TODO on devrait pouvoir le supprimer ainsi que controlUserState
const authorizedPaths = [
"/etablissement/validation",
"/formulaire/:establishment_id/by-token",
@@ -180,23 +187,33 @@ const authorizedPaths = [
"/user/status/:userId/by-token",
]
+export const verifyJwtToken = (jwtToken: string) => {
+ try {
+ const data = jwt.verify(jwtToken, config.auth.user.jwtSecret, {
+ complete: true,
+ issuer: config.publicUrl,
+ })
+ const token = data.payload as IAccessToken
+ return token
+ } catch (err) {
+ console.warn("invalid jwt token", jwtToken, err)
+ throw Boom.forbidden()
+ }
+}
+
export async function parseAccessToken(
- accessToken: string,
+ jwtToken: string,
schema: Schema,
params: PathParam | undefined,
querystring: QueryString | undefined
): Promise> {
- const data = jwt.verify(accessToken, config.auth.user.jwtSecret, {
- complete: true,
- issuer: config.publicUrl,
- })
- const token = data.payload as IAccessToken
+ const token = verifyJwtToken(jwtToken) as IAccessToken
if (token.identity.type === "IUserRecruteur") {
- const user = await getUser({ _id: token.identity._id })
+ const user = await User2.findOne({ _id: token.identity._id }).lean()
- if (!user) throw Boom.unauthorized()
+ if (!user) throw Boom.forbidden()
- const userStatus = controlUserState(user?.status)
+ const userStatus = await controlUserState(user)
if (userStatus.error && !authorizedPaths.includes(schema.path)) {
throw Boom.forbidden()
diff --git a/server/src/security/authenticationService.ts b/server/src/security/authenticationService.ts
index 40628335c5..3e555c87d5 100644
--- a/server/src/security/authenticationService.ts
+++ b/server/src/security/authenticationService.ts
@@ -1,23 +1,27 @@
import { captureException } from "@sentry/node"
import Boom from "boom"
import { FastifyRequest } from "fastify"
-import jwt, { JwtPayload } from "jsonwebtoken"
+import { JwtPayload } from "jsonwebtoken"
import { ICredential, assertUnreachable } from "shared"
import { PathParam, QueryString } from "shared/helpers/generateUri"
-import { IUserRecruteur } from "shared/models/usersRecruteur.model"
+import { IUser2 } from "shared/models/user2.model"
import { ISecuredRouteSchema, WithSecurityScheme } from "shared/routes/common.routes"
import { Role, UserWithType } from "shared/security/permissions"
import { Credential } from "@/common/model"
import config from "@/config"
import { getSession } from "@/services/sessions.service"
-import { getUser as getUserRecruteur, updateLastConnectionDate } from "@/services/userRecruteur.service"
+import { getUser2ByEmail } from "@/services/user2.service"
+import { updateLastConnectionDate } from "@/services/userRecruteur.service"
import { controlUserState } from "../services/login.service"
-import { IAccessToken, parseAccessToken } from "./accessTokenService"
+import { IAccessToken, parseAccessToken, verifyJwtToken } from "./accessTokenService"
-export type IUserWithType = UserWithType<"IUserRecruteur", IUserRecruteur> | UserWithType<"ICredential", ICredential> | UserWithType<"IAccessToken", IAccessToken>
+export type AccessUser2 = UserWithType<"IUser2", IUser2>
+export type AccessUserCredential = UserWithType<"ICredential", ICredential>
+export type AccessUserToken = UserWithType<"IAccessToken", IAccessToken>
+export type IUserWithType = AccessUser2 | AccessUserCredential | AccessUserToken
declare module "fastify" {
interface FastifyRequest {
@@ -27,11 +31,11 @@ declare module "fastify" {
}
type AuthenticatedUser = AuthScheme extends "cookie-session"
- ? UserWithType<"IUserRecruteur", IUserRecruteur>
+ ? AccessUser2
: AuthScheme extends "api-key"
- ? UserWithType<"ICredential", ICredential>
+ ? AccessUserCredential
: AuthScheme extends "access-token"
- ? UserWithType<"IAccessToken", IAccessToken>
+ ? AccessUserToken
: never
export const getUserFromRequest = (req: Pick, _schema: S): AuthenticatedUser => {
@@ -41,7 +45,7 @@ export const getUserFromRequest = (req: Pick
}
-async function authCookieSession(req: FastifyRequest): Promise | null> {
+async function authCookieSession(req: FastifyRequest): Promise {
const token = req.cookies?.[config.auth.session.cookieName]
if (!token) {
@@ -55,36 +59,36 @@ async function authCookieSession(req: FastifyRequest): Promise | null> {
+async function authApiKey(req: FastifyRequest): Promise {
const token = req.headers.authorization
if (token === null) {
return null
}
- const user = await Credential.findOne({ api_key: token }).lean()
+ const user = await Credential.findOne({ api_key: token, actif: true }).lean()
return user ? { type: "ICredential", value: user } : null
}
@@ -102,7 +106,7 @@ function extractBearerTokenFromHeader(req: FastifyRequest): null | string {
return matches === null ? null : matches[1]
}
-async function authAccessToken(req: FastifyRequest, schema: S): Promise | null> {
+async function authAccessToken(req: FastifyRequest, schema: S): Promise {
const token = extractBearerTokenFromHeader(req)
if (token === null) {
return null
diff --git a/server/src/security/authorisationService.ts b/server/src/security/authorisationService.ts
index f89979e6fb..59f368a602 100644
--- a/server/src/security/authorisationService.ts
+++ b/server/src/security/authorisationService.ts
@@ -1,23 +1,36 @@
import Boom from "boom"
import { FastifyRequest } from "fastify"
-import { LBA_ITEM_TYPE } from "shared/constants/lbaitem"
-import { IApplication, ICredential, IJob, IRecruiter, IUserRecruteur } from "shared/models"
+import { ADMIN, CFA, ENTREPRISE, OPCOS } from "shared/constants/recruteur"
+import { ComputedUserAccess, IApplication, IJob, IRecruiter } from "shared/models"
+import { ICFA } from "shared/models/cfa.model"
+import { IEntreprise } from "shared/models/entreprise.model"
+import { AccessEntityType, IRoleManagement } from "shared/models/roleManagement.model"
+import { UserEventType } from "shared/models/user2.model"
import { IRouteSchema, WithSecurityScheme } from "shared/routes/common.routes"
-import { AccessPermission, AccessResourcePath, AdminRole, CfaRole, OpcoRole, PendingRecruiterRole, RecruiterRole, Role, UserWithType } from "shared/security/permissions"
-import { assertUnreachable } from "shared/utils"
+import { AccessPermission, AccessResourcePath } from "shared/security/permissions"
+import { assertUnreachable, parseEnum } from "shared/utils"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
import { Primitive } from "type-fest"
-import { Application, Recruiter, UserRecruteur } from "@/common/model"
-
-import { controlUserState } from "../services/login.service"
+import { Application, Cfa, Entreprise, Recruiter, User2 } from "@/common/model"
+import { ObjectId } from "@/common/mongodb"
+import { getComputedUserAccess, getGrantedRoles } from "@/services/roleManagement.service"
+import { getUser2ByEmail } from "@/services/user2.service"
+import { isUserEmailChecked } from "@/services/userRecruteur.service"
import { getUserFromRequest } from "./authenticationService"
+type RecruiterResource = { recruiter: IRecruiter } & ({ type: "ENTREPRISE"; entreprise: IEntreprise } | { type: "CFA"; cfa: ICFA })
+type JobResource = { job: IJob; recruiterResource: RecruiterResource }
+type ApplicationResource = { application: IApplication; jobResource?: JobResource; applicantId?: string }
+type EntrepriseResource = { entreprise: IEntreprise }
+
type Resources = {
- recruiters: Array
- jobs: Array<{ job: IJob; recruiter: IRecruiter } | null>
- users: Array
- applications: Array<{ application: IApplication; job: IJob; recruiter: IRecruiter } | null>
+ users: Array<{ _id: string }>
+ recruiters: Array
+ jobs: Array
+ applications: Array
+ entreprises: Array
}
export type ResourceIds = {
recruiters?: string[]
@@ -29,8 +42,6 @@ export type ResourceIds = {
// Specify what we need to simplify mocking in tests
type IRequest = Pick
-type NonTokenUserWithType = UserWithType<"IUserRecruteur", IUserRecruteur> | UserWithType<"ICredential", ICredential>
-
// TODO: Unit test access control
// TODO: job.delegations
// TODO: Unit schema access path properly defined (exists in Zod schema)
@@ -40,12 +51,34 @@ function getAccessResourcePathValue(path: AccessResourcePath, req: IRequest): an
return obj[path.key]
}
+const recruiterToRecruiterResource = async (recruiter: IRecruiter): Promise => {
+ const { cfa_delegated_siret, establishment_siret } = recruiter
+ if (cfa_delegated_siret) {
+ const cfa = await Cfa.findOne({ siret: cfa_delegated_siret }).lean()
+ if (!cfa) {
+ throw Boom.internal(`could not find cfa for recruiter with id=${recruiter._id}`)
+ }
+ return { recruiter, type: CFA, cfa }
+ } else {
+ const entreprise = await Entreprise.findOne({ siret: establishment_siret }).lean()
+ if (!entreprise) {
+ throw Boom.internal(`could not find entreprise for recruiter with id=${recruiter._id}`)
+ }
+ return { recruiter, type: ENTREPRISE, entreprise }
+ }
+}
+
+const jobToJobResource = async (job: IJob, recruiter: IRecruiter): Promise => {
+ const recruiterResource = await recruiterToRecruiterResource(recruiter)
+ return { job, recruiterResource }
+}
+
async function getRecruitersResource(schema: S, req: IRequest): Promise {
if (!schema.securityScheme.resources.recruiter) {
return []
}
- return (
+ const recruiters: IRecruiter[] = (
await Promise.all(
schema.securityScheme.resources.recruiter.map(async (recruiterDef): Promise => {
if ("_id" in recruiterDef) {
@@ -74,6 +107,7 @@ async function getRecruitersResource(schema: S, re
})
)
).flatMap((_) => _)
+ return (await Promise.all(recruiters.map(recruiterToRecruiterResource))).flatMap((_) => (_ ? [_] : []))
}
async function getJobsResource(schema: S, req: IRequest): Promise {
@@ -81,28 +115,29 @@ async function getJobsResource(schema: S, req: IRe
return []
}
- return Promise.all(
- schema.securityScheme.resources.job.map(async (j) => {
- if ("_id" in j) {
- const id = getAccessResourcePathValue(j._id, req)
- const recruiter = await Recruiter.findOne({ "jobs._id": id }).lean()
+ return (
+ await Promise.all(
+ schema.securityScheme.resources.job.map(async (jobDef): Promise => {
+ if ("_id" in jobDef) {
+ const id = getAccessResourcePathValue(jobDef._id, req)
+ const recruiter = await Recruiter.findOne({ "jobs._id": id }).lean()
- if (!recruiter) {
- return null
- }
+ if (!recruiter) {
+ return null
+ }
- const job = await recruiter.jobs.find((j) => j._id.toString() === id.toString())
+ const job = await recruiter.jobs.find((j) => j._id.toString() === id.toString())
- if (!job) {
- return null
+ if (!job) {
+ return null
+ }
+ return jobToJobResource(job, recruiter)
}
- return { recruiter, job }
- }
-
- assertUnreachable(j)
- })
- )
+ assertUnreachable(jobDef)
+ })
+ )
+ ).flatMap((_) => (_ ? [_] : []))
}
async function getUserResource(schema: S, req: IRequest): Promise {
@@ -114,60 +149,85 @@ async function getUserResource(schema: S, req: IRe
await Promise.all(
schema.securityScheme.resources.user.map(async (userDef) => {
if ("_id" in userDef) {
- const userOpt = await UserRecruteur.findById(getAccessResourcePathValue(userDef._id, req)).lean()
- return userOpt ? [userOpt] : []
- }
- if ("opco" in userDef) {
- return UserRecruteur.find({ opco: getAccessResourcePathValue(userDef.opco, req) }).lean()
+ const userOpt = await User2.findOne({ _id: getAccessResourcePathValue(userDef._id, req) }).lean()
+ return userOpt ? { _id: userOpt._id.toString() } : null
}
-
assertUnreachable(userDef)
})
)
- ).flatMap((_) => _)
+ ).flatMap((_) => (_ ? [_] : []))
}
-async function getApplicationResouce(schema: S, req: IRequest): Promise {
+async function getApplicationResource(schema: S, req: IRequest): Promise {
if (!schema.securityScheme.resources.application) {
return []
}
- return Promise.all(
- schema.securityScheme.resources.application.map(async (u) => {
- if ("_id" in u) {
- const id = getAccessResourcePathValue(u._id, req)
+ const results: (ApplicationResource | null)[] = await Promise.all(
+ schema.securityScheme.resources.application.map(async (applicationDef): Promise => {
+ if ("_id" in applicationDef) {
+ const id = getAccessResourcePathValue(applicationDef._id, req)
const application = await Application.findById(id).lean()
- if (!application || !application.job_id) return null
-
- const jobId = application.job_id
-
- const recruiter = await Recruiter.findOne({ "jobs._id": jobId }).lean()
-
+ if (!application) return null
+ const { job_id } = application
+ if (!job_id) {
+ return { application }
+ }
+ const recruiter = await Recruiter.findOne({ "jobs._id": job_id }).lean()
if (!recruiter) {
- return null
+ return { application }
}
+ const job = recruiter.jobs.find((job) => job._id.toString() === job_id)
+ if (!job) {
+ return { application }
+ }
+ const jobResource = await jobToJobResource(job, recruiter)
+ const user = await getUser2ByEmail(application.applicant_email)
+ return { application, jobResource, applicantId: user?._id.toString() }
+ }
- const job = recruiter.jobs.find((j) => j._id.toString() === jobId.toString())
+ assertUnreachable(applicationDef)
+ })
+ )
+ return results.flatMap((_) => (_ ? [_] : []))
+}
- if (!job) {
+async function getEntrepriseResource(schema: S, req: IRequest): Promise {
+ if (!schema.securityScheme.resources.entreprise) {
+ return []
+ }
+
+ const results: (EntrepriseResource | null)[] = await Promise.all(
+ schema.securityScheme.resources.entreprise.map(async (entrepriseDef): Promise => {
+ if ("siret" in entrepriseDef) {
+ const siret = getAccessResourcePathValue(entrepriseDef.siret, req)
+ const entreprise = await Entreprise.findOne({ siret }).lean()
+ return entreprise ? { entreprise } : null
+ } else if ("_id" in entrepriseDef) {
+ const id = getAccessResourcePathValue(entrepriseDef._id, req)
+ try {
+ new ObjectId(id)
+ } catch (e) {
return null
}
-
- return { application, recruiter, job }
+ const entreprise = await Entreprise.findById(id).lean()
+ return entreprise ? { entreprise } : null
}
- assertUnreachable(u)
+ assertUnreachable(entrepriseDef)
})
)
+ return results.flatMap((_) => (_ ? [_] : []))
}
-export async function getResources(schema: S, req: IRequest): Promise {
- const [recruiters, jobs, users, applications] = await Promise.all([
+async function getResources(schema: S, req: IRequest): Promise {
+ const [recruiters, jobs, users, applications, entreprises] = await Promise.all([
getRecruitersResource(schema, req),
getJobsResource(schema, req),
getUserResource(schema, req),
- getApplicationResouce(schema, req),
+ getApplicationResource(schema, req),
+ getEntrepriseResource(schema, req),
])
return {
@@ -175,231 +235,63 @@ export async function getResources(schema: S, req:
jobs,
users,
applications,
+ entreprises,
}
}
-const getResourceIds = (resources: Resources): ResourceIds => {
- const resourcesIds: ResourceIds = {}
-
- Object.keys(resources).map((key) => {
- switch (key) {
- case "recruiters": {
- if (resources.recruiters.length) {
- resourcesIds.recruiters = resources.recruiters.map((recruiter) => recruiter._id.toString())
- }
- break
- }
- case "users": {
- if (resources.users.length) {
- resourcesIds.users = resources.users.map((user) => user._id.toString())
- }
- break
- }
- case "jobs": {
- if (resources.jobs.length) {
- resourcesIds.jobs = resources.jobs.map((job) =>
- job
- ? {
- job: job.job._id.toString(),
- recruiter: job.recruiter ? job?.recruiter._id.toString() : null,
- }
- : null
- )
- }
- break
- }
- case "applications": {
- if (resources.applications.length) {
- resourcesIds.applications = resources.applications.map((application) =>
- application
- ? {
- application: application.application._id.toString(),
- job: application.job._id.toString(),
- recruiter: application.recruiter._id.toString(),
- }
- : null
- )
- }
- break
- }
- default:
- assertUnreachable(key as never)
- }
- })
-
- return resourcesIds
-}
-
-export function getUserRole(userWithType: NonTokenUserWithType): Role | null {
- if (userWithType.type === "ICredential") {
- return OpcoRole
- }
- const userState = controlUserState(userWithType.value.status)
-
- switch (userWithType.value.type) {
- case "ADMIN":
- return AdminRole
- case "CFA":
- return CfaRole
- case "ENTREPRISE":
- if (userState.error) {
- if (userState.reason !== "VALIDATION") throw Boom.internal("Unexpected state during user role validation")
- return PendingRecruiterRole
- } else {
- return RecruiterRole
- }
- case "OPCO":
- return OpcoRole
- default:
- return assertUnreachable(userWithType.value.type)
- }
-}
-
-function canAccessRecruiter(userWithType: NonTokenUserWithType, resource: Resources["recruiters"][number]): boolean {
- if (resource === null) {
+function canAccessRecruiter(userAccess: ComputedUserAccess, resource: RecruiterResource): boolean {
+ const recruiterOpco = parseEnum(OPCOS, resource.recruiter.opco ?? null)
+ if (recruiterOpco && userAccess.opcos.includes(recruiterOpco)) {
return true
}
-
- if (userWithType.type === "ICredential") {
- return resource.opco === userWithType.value.organisation
- }
-
- const user = userWithType.value
- switch (user.type) {
- case "ADMIN":
- return true
- case "ENTREPRISE":
- return resource.establishment_id === user.establishment_id
- case "CFA":
- return resource.cfa_delegated_siret === user.establishment_siret
- case "OPCO":
- return resource.opco === user.scope
- default:
- assertUnreachable(user.type)
+ if (resource.type === ENTREPRISE) {
+ return userAccess.entreprises.includes(resource.entreprise._id.toString())
+ } else if (resource.type === CFA) {
+ return userAccess.cfas.includes(resource.cfa._id.toString())
}
+ return false
}
-function canAccessJob(userWithType: NonTokenUserWithType, resource: Resources["jobs"][number]): boolean {
- if (resource === null) {
- return true
- }
-
- if (userWithType.type === "ICredential") {
- return resource.recruiter.opco === userWithType.value.organisation
- }
-
- const user = userWithType.value
- switch (user.type) {
- case "ADMIN":
- return true
- case "ENTREPRISE":
- return resource.recruiter.establishment_id === user.establishment_id
- case "CFA":
- return resource.recruiter.cfa_delegated_siret === user.establishment_siret
- case "OPCO":
- return resource.recruiter.opco === user.scope
- default:
- assertUnreachable(user.type)
- }
+function canAccessJob(userAccess: ComputedUserAccess, resource: JobResource): boolean {
+ return canAccessRecruiter(userAccess, resource.recruiterResource)
}
-function canAccessUser(userWithType: NonTokenUserWithType, resource: Resources["users"][number]): boolean {
- if (resource === null) {
+function canAccessUser(userAccess: ComputedUserAccess, resource: Resources["users"][number]): boolean {
+ if (userAccess.opcos.length) {
return true
}
-
- if (userWithType.type === "ICredential") {
- return resource.type === "OPCO" && resource.scope === userWithType.value.organisation
- }
-
- if (resource._id.toString() === userWithType.value._id.toString()) {
- return true
- }
-
- const user = userWithType.value
- switch (user.type) {
- case "ADMIN":
- return true
- case "ENTREPRISE":
- return resource._id.toString() === user._id.toString()
- case "CFA":
- return resource._id.toString() === user._id.toString()
- case "OPCO":
- return (resource.type === "OPCO" && resource._id === user._id) || (resource.type === "ENTREPRISE" && resource.opco === user.scope)
- default:
- assertUnreachable(user.type)
- }
+ return userAccess.users.includes(resource._id)
}
-function canAccessApplication(userWithType: NonTokenUserWithType, resource: Resources["applications"][number]): boolean {
- if (resource === null) {
- return true
- }
-
- if (userWithType.type === "ICredential") {
- return false
- }
-
- const user = userWithType.value
- switch (user.type) {
- case "ADMIN":
- return true
- case "ENTREPRISE": {
- if (resource.application.job_origin === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA) {
- return resource.recruiter.establishment_id === userWithType.value.establishment_id
- }
+function canAccessApplication(userAccess: ComputedUserAccess, resource: ApplicationResource): boolean {
+ const { jobResource, applicantId } = resource
+ // TODO ajout de granularité pour les accès candidat et recruteur
+ return (jobResource && canAccessJob(userAccess, jobResource)) || (applicantId ? canAccessUser(userAccess, { _id: applicantId }) : false)
+}
- return false
- }
- case "CFA":
- return false
- case "OPCO":
- return false
- default:
- assertUnreachable(user.type)
- }
+function canAccessEntreprise(userAccess: ComputedUserAccess, resource: EntrepriseResource): boolean {
+ const { entreprise } = resource
+ const entrepriseOpco = parseEnum(OPCOS, entreprise.opco)
+ return userAccess.entreprises.includes(entreprise._id.toString()) || Boolean(entrepriseOpco && userAccess.opcos.includes(entrepriseOpco))
}
-export function isAuthorized(access: AccessPermission, userWithType: NonTokenUserWithType, role: Role | null, resources: Resources): boolean {
+function isAuthorized(access: AccessPermission, userAccess: ComputedUserAccess, resources: Resources): boolean {
if (typeof access === "object") {
if ("some" in access) {
- return access.some.some((a) => isAuthorized(a, userWithType, role, resources))
- }
-
- if ("every" in access) {
- return access.every.every((a) => isAuthorized(a, userWithType, role, resources))
- }
-
- assertUnreachable(access)
- }
-
- // Role is null for access token but we have permission
- if (role && !role.permissions.includes(access)) {
- return false
- }
-
- switch (access) {
- case "recruiter:manage":
- case "recruiter:add_job":
- return resources.recruiters.every((recruiter) => canAccessRecruiter(userWithType, recruiter))
-
- case "job:manage":
- return resources.jobs.every((job) => canAccessJob(userWithType, job))
-
- case "school:manage":
- // School is actually the UserRecruteur
- return resources.users.every((user) => canAccessUser(userWithType, user))
- case "application:manage":
- return resources.applications.every((application) => canAccessApplication(userWithType, application))
- case "user:validate":
- case "user:manage":
- return resources.users.every((user) => canAccessUser(userWithType, user))
- case "admin":
- // Admin should already have been approved, otherwise you cannot access to admin
- return false
- default:
+ return access.some.some((permission) => isAuthorized(permission, userAccess, resources))
+ } else if ("every" in access) {
+ return access.every.every((permission) => isAuthorized(permission, userAccess, resources))
+ } else {
assertUnreachable(access)
+ }
}
+ return (
+ resources.recruiters.every((recruiter) => canAccessRecruiter(userAccess, recruiter)) &&
+ resources.jobs.every((job) => canAccessJob(userAccess, job)) &&
+ resources.applications.every((application) => canAccessApplication(userAccess, application)) &&
+ resources.users.every((user) => canAccessUser(userAccess, user)) &&
+ resources.entreprises.every((entreprise) => canAccessEntreprise(userAccess, entreprise))
+ )
}
export async function authorizationMiddleware & WithSecurityScheme>(schema: S, req: IRequest) {
@@ -407,26 +299,68 @@ export async function authorizationMiddleware role.authorized_type === AccessEntityType.ADMIN)
+ if (isAdmin) {
+ return
+ }
+ if (!grantedRoles.length) {
+ throw Boom.forbidden("aucun role")
+ }
+ }
+
+ if (requestedAccess === "admin") {
+ throw Boom.forbidden("admin required")
+ }
const resources = await getResources(schema, req)
- const role = getUserRole(userWithType)
- req.authorizationContext = { role, resources: getResourceIds(resources) }
- if (!isAuthorized(schema.securityScheme.access, userWithType, role, resources)) {
- throw Boom.forbidden()
+ if (userType === "ICredential") {
+ const { organisation } = userWithType.value
+ if (organisation.toLowerCase() === ADMIN.toLowerCase()) {
+ return
+ }
+ const opco = parseEnum(OPCOS, organisation)
+ const userAccess: ComputedUserAccess = {
+ admin: false,
+ users: [],
+ cfas: [],
+ entreprises: [],
+ opcos: opco ? [opco] : [],
+ }
+ if (!isAuthorized(requestedAccess, userAccess, resources)) {
+ throw Boom.forbidden("non autorisé")
+ }
+ } else if (userType === "IUser2") {
+ const { _id } = userWithType.value
+ const userAccess: ComputedUserAccess = getComputedUserAccess(_id.toString(), grantedRoles)
+ if (!isAuthorized(requestedAccess, userAccess, resources)) {
+ throw Boom.forbidden("non autorisé")
+ }
+ } else {
+ assertUnreachable(userType)
}
}
diff --git a/server/src/services/appLinks.service.ts b/server/src/services/appLinks.service.ts
index f15a9c44af..567d8fa675 100644
--- a/server/src/services/appLinks.service.ts
+++ b/server/src/services/appLinks.service.ts
@@ -1,10 +1,11 @@
-import { IJob, IUserRecruteur } from "shared/models"
+import { IJob } from "shared/models"
+import { IUser2 } from "shared/models/user2.model"
import { zRoutes } from "shared/routes"
import config from "@/config"
-import { UserForAccessToken, generateAccessToken, generateScope } from "@/security/accessTokenService"
+import { IUser2ForAccessToken, UserForAccessToken, generateAccessToken, generateScope, user2ToUserForToken } from "@/security/accessTokenService"
-export function createAuthMagicLinkToken(user: IUserRecruteur) {
+export function createAuthMagicLinkToken(user: UserForAccessToken) {
return generateAccessToken(user, [
generateScope({
schema: zRoutes.post["/login/verification"],
@@ -16,13 +17,13 @@ export function createAuthMagicLinkToken(user: IUserRecruteur) {
])
}
-export function createAuthMagicLink(user: IUserRecruteur) {
+export function createAuthMagicLink(user: UserForAccessToken) {
const token = createAuthMagicLinkToken(user)
return `${config.publicUrl}/espace-pro/authentification/verification?token=${encodeURIComponent(token)}`
}
-export function createValidationMagicLink(user: IUserRecruteur) {
+export function createValidationMagicLink(user: IUser2ForAccessToken) {
const token = generateAccessToken(
user,
[
@@ -80,7 +81,7 @@ export function createCfaUnsubscribeToken(email: string, siret: string) {
)
}
-export function createCancelJobLink(user: IUserRecruteur, jobId: string, utmData: string | undefined = undefined) {
+export function createCancelJobLink(user: UserForAccessToken, jobId: string, utmData: string | undefined = undefined) {
const token = generateAccessToken(
user,
[
@@ -102,7 +103,7 @@ export function createCancelJobLink(user: IUserRecruteur, jobId: string, utmData
return `${config.publicUrl}/espace-pro/offre/${jobId}/cancel?${utmData ? utmData : ""}&token=${token}`
}
-export function createProvidedJobLink(user: IUserRecruteur, jobId: string, utmData: string | undefined = undefined) {
+export function createProvidedJobLink(user: UserForAccessToken, jobId: string, utmData: string | undefined = undefined) {
const token = generateAccessToken(
user,
[
@@ -319,11 +320,7 @@ export function generateApplicationReplyToken(tokenUser: UserForAccessToken, app
)
}
-export function generateDepotSimplifieToken(user: IUserRecruteur) {
- if (!user.establishment_id) {
- throw new Error("unexpected")
- }
- const establishment_id = user.establishment_id
+export function generateDepotSimplifieToken(user: IUser2ForAccessToken, establishment_id: string) {
return generateAccessToken(
user,
[
@@ -355,9 +352,9 @@ export function generateDepotSimplifieToken(user: IUserRecruteur) {
)
}
-export function generateOffreToken(user: IUserRecruteur, offre: IJob) {
+export function generateOffreToken(user: IUser2, offre: IJob) {
return generateAccessToken(
- user,
+ user2ToUserForToken(user),
[
generateScope({
schema: zRoutes.post["/formulaire/offre/:jobId/delegation/by-token"],
@@ -373,6 +370,13 @@ export function generateOffreToken(user: IUserRecruteur, offre: IJob) {
querystring: undefined,
},
}),
+ generateScope({
+ schema: zRoutes.post["/login/:userId/resend-confirmation-email"],
+ options: {
+ params: { userId: user._id.toString() },
+ querystring: undefined,
+ },
+ }),
],
{
expiresIn: "2h",
diff --git a/server/src/services/application.service.ts b/server/src/services/application.service.ts
index 1ca4a79a9e..4d243e2242 100644
--- a/server/src/services/application.service.ts
+++ b/server/src/services/application.service.ts
@@ -2,18 +2,19 @@ import Boom from "boom"
import { isEmailBurner } from "burner-email-providers"
import Joi from "joi"
import type { EnforceDocument } from "mongoose"
-import { IApplication, IJob, ILbaCompany, INewApplicationV2, IRecruiter, IUserRecruteur, JOB_STATUS, ZApplication, assertUnreachable } from "shared"
+import { IApplication, IJob, ILbaCompany, INewApplicationV2, IRecruiter, JOB_STATUS, ZApplication, assertUnreachable } from "shared"
import { ApplicantIntention } from "shared/constants/application"
import { BusinessErrorCodes } from "shared/constants/errorCodes"
import { LBA_ITEM_TYPE, LBA_ITEM_TYPE_OLD, newItemTypeToOldItemType } from "shared/constants/lbaitem"
import { RECRUITER_STATUS } from "shared/constants/recruteur"
import { prepareMessageForMail, removeUrlsFromText } from "shared/helpers/common"
+import { IUser2 } from "shared/models/user2.model"
import { getStaticFilePath } from "@/common/utils/getStaticFilePath"
-import { UserForAccessToken } from "@/security/accessTokenService"
+import { UserForAccessToken, user2ToUserForToken } from "@/security/accessTokenService"
import { logger } from "../common/logger"
-import { Application, EmailBlacklist, LbaCompany, Recruiter, UserRecruteur } from "../common/model"
+import { Application, EmailBlacklist, LbaCompany, Recruiter, User2 } from "../common/model"
import { manageApiError } from "../common/utils/errorManager"
import { sentryCaptureException } from "../common/utils/sentryUtils"
import config from "../config"
@@ -21,7 +22,7 @@ import config from "../config"
import { createCancelJobLink, createProvidedJobLink, generateApplicationReplyToken } from "./appLinks.service"
import { BrevoEventStatus } from "./brevo.service"
import { scan } from "./clamav.service"
-import { getOffreAvecInfoMandataire } from "./formulaire.service"
+import { getJobFromRecruiter, getOffreAvecInfoMandataire } from "./formulaire.service"
import { buildLbaCompanyAddress } from "./lbacompany.service"
import mailer, { sanitizeForEmail } from "./mailer.service"
import { validateCaller } from "./queryValidator.service"
@@ -244,21 +245,21 @@ const buildUrlsOfDetail = (publicUrl: string, offreOrCompany: OffreOrLbbCompany)
}
}
-const buildUserToken = (application: IApplication, userRecruteur?: IUserRecruteur): UserForAccessToken => {
+const buildUserForToken = (application: IApplication, user?: IUser2): UserForAccessToken => {
const { job_origin, company_siret, company_email } = application
if (job_origin === LBA_ITEM_TYPE.RECRUTEURS_LBA) {
return { type: "lba-company", siret: company_siret, email: company_email }
} else if (job_origin === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA) {
- if (!userRecruteur) {
+ if (!user) {
throw Boom.internal("un user recruteur était attendu")
}
- return userRecruteur
+ return user2ToUserForToken(user)
} else {
throw Boom.internal(`job_origin=${job_origin} non supporté`)
}
}
-const buildReplyLink = (application: IApplication, intention: ApplicantIntention, userRecruteur?: IUserRecruteur) => {
+const buildReplyLink = (application: IApplication, intention: ApplicantIntention, userForToken: UserForAccessToken) => {
const applicationId = application._id.toString()
const searchParams = new URLSearchParams()
searchParams.append("company_recruitment_intention", intention)
@@ -268,11 +269,24 @@ const buildReplyLink = (application: IApplication, intention: ApplicantIntention
searchParams.append("utm_source", "jecandidate")
searchParams.append("utm_medium", "email")
searchParams.append("utm_campaign", "jecandidaterecruteur")
- const token = generateApplicationReplyToken(buildUserToken(application, userRecruteur), applicationId)
+ const token = generateApplicationReplyToken(userForToken, applicationId)
searchParams.append("token", token)
return `${config.publicUrl}/formulaire-intention?${searchParams.toString()}`
}
+export const getUser2ManagingOffer = async (job: Pick): Promise => {
+ const { managed_by } = job
+ if (managed_by) {
+ const user = await User2.findOne({ _id: managed_by }).lean()
+ if (!user) {
+ throw new Error(`could not find offer manager with id=${managed_by}`)
+ }
+ return user
+ } else {
+ throw new Error(`unexpected: managed_by is empty for offer with id=${job._id}`)
+ }
+}
+
/**
* Build urls to add in email messages sent to the recruiter
*/
@@ -280,22 +294,19 @@ const buildRecruiterEmailUrls = async (application: IApplication) => {
const utmRecruiterData = "&utm_source=jecandidate&utm_medium=email&utm_campaign=jecandidaterecruteur"
// get the related recruiters to fetch it's establishment_id
- let userRecruteur: IUserRecruteur | undefined
+ let user: IUser2 | undefined
if (application.job_id) {
const recruiter = await Recruiter.findOne({ "jobs._id": application.job_id }).lean()
if (recruiter) {
- if (recruiter.is_delegated) {
- userRecruteur = await UserRecruteur.findOne({ establishment_siret: recruiter.cfa_delegated_siret }).lean()
- } else {
- userRecruteur = await UserRecruteur.findOne({ establishment_id: recruiter.establishment_id }).lean()
- }
+ user = await getUser2ManagingOffer(getJobFromRecruiter(recruiter, application.job_id))
}
}
+ const userForToken = buildUserForToken(application, user)
const urls = {
- meetCandidateUrl: buildReplyLink(application, ApplicantIntention.ENTRETIEN, userRecruteur),
- waitCandidateUrl: buildReplyLink(application, ApplicantIntention.NESAISPAS, userRecruteur),
- refuseCandidateUrl: buildReplyLink(application, ApplicantIntention.REFUS, userRecruteur),
+ meetCandidateUrl: buildReplyLink(application, ApplicantIntention.ENTRETIEN, userForToken),
+ waitCandidateUrl: buildReplyLink(application, ApplicantIntention.NESAISPAS, userForToken),
+ refuseCandidateUrl: buildReplyLink(application, ApplicantIntention.REFUS, userForToken),
lbaRecruiterUrl: `${config.publicUrl}/acces-recruteur?${utmRecruiterData}`,
unsubscribeUrl: `${config.publicUrl}/desinscription?email=${application.company_email}${utmRecruiterData}`,
lbaUrl: `${config.publicUrl}?${utmRecruiterData}`,
@@ -304,9 +315,9 @@ const buildRecruiterEmailUrls = async (application: IApplication) => {
cancelJobUrl: "",
}
- if (application.job_id && userRecruteur) {
- urls.jobProvidedUrl = createProvidedJobLink(userRecruteur, application.job_id, utmRecruiterData)
- urls.cancelJobUrl = createCancelJobLink(userRecruteur, application.job_id, utmRecruiterData)
+ if (application.job_id && user) {
+ urls.jobProvidedUrl = createProvidedJobLink(userForToken, application.job_id, utmRecruiterData)
+ urls.cancelJobUrl = createCancelJobLink(userForToken, application.job_id, utmRecruiterData)
}
return urls
diff --git a/server/src/services/constant.service.ts b/server/src/services/constant.service.ts
index 49591bc8b7..dbe883d697 100644
--- a/server/src/services/constant.service.ts
+++ b/server/src/services/constant.service.ts
@@ -8,29 +8,9 @@ export enum RECRUITER_STATUS {
EN_ATTENTE_VALIDATION = "En attente de validation",
}
-export const KEY_GENERATOR_PARAMS = ({ length, symbols, numbers }) => {
- return {
- length: length ?? 50,
- strict: true,
- numbers: numbers ?? true,
- symbols: symbols ?? true,
- lowercase: true,
- uppercase: false,
- excludeSimilarCharacters: true,
- exclude: '!"_%£$€*¨^=+~ß(){}[]§;,./:`@#&|<>?"',
- }
-}
-export const ENTREPRISE_DELEGATION = "ENTREPRISE_DELEGATION"
-
export const ADMIN = "ADMIN"
export const ENTREPRISE = "ENTREPRISE"
export const CFA = "CFA"
-export const OPCO = "OPCO"
-export const REGEX = {
- SIRET: /^([0-9]{9}|[0-9]{14})$/,
- GEO: /^(-?\d+(\.\d+)?),\s*(-?\d+(\.\d+)?)$/,
- TELEPHONE: /^[0-9]{10}$/,
-}
export const NIVEAUX_POUR_LBA = {
INDIFFERENT: "Indifférent",
@@ -56,13 +36,3 @@ export enum UNSUBSCRIBE_EMAIL_ERRORS {
ETABLISSEMENTS_MULTIPLES = "ETABLISSEMENTS_MULTIPLES",
WRONG_PARAMETERS = "WRONG_PARAMETERS",
}
-
-export const ROLES = {
- candidat: "candidat",
- cfa: "cfa",
- administrator: "administrator",
-}
-
-export type IRoles = typeof ROLES
-
-export type IRole = IRoles[keyof IRoles]
diff --git a/server/src/services/etablissement.service.ts b/server/src/services/etablissement.service.ts
index c34ac4468f..ba372fbffb 100644
--- a/server/src/services/etablissement.service.ts
+++ b/server/src/services/etablissement.service.ts
@@ -3,16 +3,21 @@ import { setTimeout } from "timers/promises"
import { AxiosResponse } from "axios"
import Boom from "boom"
import type { FilterQuery } from "mongoose"
-import { IAdresseV3, IBusinessError, ICfaReferentielData, IEtablissement, ILbaCompany, IRecruiter, IReferentielOpco, IUserRecruteur, ZCfaReferentielData } from "shared"
+import { IAdresseV3, IBusinessError, ICfaReferentielData, IEtablissement, ILbaCompany, IRecruiter, IReferentielOpco, ZAdresseV3, ZCfaReferentielData } from "shared"
import { EDiffusibleStatus } from "shared/constants/diffusibleStatus"
import { BusinessErrorCodes } from "shared/constants/errorCodes"
-import { ETAT_UTILISATEUR } from "shared/constants/recruteur"
+import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur"
+import { EntrepriseStatus } from "shared/models/entreprise.model"
+import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model"
+import { IUser2 } from "shared/models/user2.model"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
import { FCGetOpcoInfos } from "@/common/franceCompetencesClient"
import { getStaticFilePath } from "@/common/utils/getStaticFilePath"
import { getHttpClient } from "@/common/utils/httpUtils"
+import { user2ToUserForToken } from "@/security/accessTokenService"
-import { Etablissement, LbaCompany, LbaCompanyLegacy, ReferentielOpco, SiretDiffusibleStatus, UnsubscribeOF, UserRecruteur } from "../common/model/index"
+import { Cfa, Etablissement, LbaCompany, LbaCompanyLegacy, ReferentielOpco, RoleManagement, SiretDiffusibleStatus, UnsubscribeOF, User2 } from "../common/model/index"
import { isEmailFromPrivateCompany, isEmailSameDomain } from "../common/utils/mailUtils"
import { sentryCaptureException } from "../common/utils/sentryUtils"
import config from "../config"
@@ -35,7 +40,17 @@ import {
import { createFormulaire, getFormulaire } from "./formulaire.service"
import mailer, { sanitizeForEmail } from "./mailer.service"
import { getOpcoBySirenFromDB, saveOpco } from "./opco.service"
-import { autoValidateUser, createUser, getUser, getUserStatus, setUserHasToBeManuallyValidated, setUserInError } from "./userRecruteur.service"
+import { modifyPermissionToUser } from "./roleManagement.service"
+import {
+ UserAndOrganization,
+ autoValidateUser as authorizeUserOnEntreprise,
+ createOrganizationUser,
+ getUserRecruteurByEmail,
+ isUserEmailChecked,
+ setEntrepriseInError,
+ setEntrepriseValid,
+ setUserHasToBeManuallyValidated,
+} from "./userRecruteur.service"
const apiParams = {
token: config.entreprise.apiKey,
@@ -181,13 +196,6 @@ export const findByIdAndUpdate = async (id, values): Promise => Etablissement.findByIdAndDelete(id).lean()
-/**
- * @description Get etablissement from a given query
- * @param {Object} query
- * @returns {Promise}
- */
-export const getEtablissement = async (query: FilterQuery): Promise => UserRecruteur.findOne(query).lean()
-
/**
* @description Get opco details from CFADOCK API for a given SIRET
* @param {String} siret
@@ -247,14 +255,6 @@ export const getIdcc = async (siret: string): Promise => {
}
}
-/**
- * @description Validate the establishment email for a given ID
- * @param {IUserRecruteur["_id"]} _id
- * @returns {Promise}
- */
-export const validateEtablissementEmail = async (email: IUserRecruteur["email"]): Promise =>
- UserRecruteur.findOneAndUpdate({ email }, { is_email_checked: true })
-
/**
* @description Get the establishment information from the ENTREPRISE API for a given SIRET
*/
@@ -269,6 +269,7 @@ export const getEtablissementFromGouvSafe = async (siret: string): Promise {
- const validated = await isCompanyValid(userRecruteur)
+export const autoValidateUserRoleOnCompany = async (userAndEntreprise: UserAndOrganization, origin: string) => {
+ const { isValid: validated, validator } = await isCompanyValid(userAndEntreprise)
+ const reason = `validaton par : ${validator}`
if (validated) {
- userRecruteur = await autoValidateUser(userRecruteur._id)
+ await authorizeUserOnEntreprise(userAndEntreprise, origin, reason)
} else {
- if (!(userRecruteur.status.length && getUserStatus(userRecruteur.status) === ETAT_UTILISATEUR.ATTENTE)) {
- userRecruteur = await setUserHasToBeManuallyValidated(userRecruteur._id)
- }
+ await setUserHasToBeManuallyValidated(userAndEntreprise, origin, reason)
}
- return { userRecruteur, validated }
+ return { validated }
}
-export const isCompanyValid = async (userRecruteur: IUserRecruteur) => {
- const { establishment_siret: siret, email } = userRecruteur
+export const isCompanyValid = async (props: UserAndOrganization): Promise<{ isValid: boolean; validator: string }> => {
+ const {
+ organization: { siret },
+ user: { email },
+ } = props
if (!siret) {
- return false
+ return { isValid: false, validator: "siret manquant" }
}
const siren = siret.slice(0, 9)
@@ -598,13 +601,12 @@ export const isCompanyValid = async (userRecruteur: IUserRecruteur) => {
const validEmails = [...new Set([...referentielOpcoEmailList, ...bonneBoiteLegacyEmailList, ...bonneBoiteEmailList])]
// Check BAL API for validation
-
const isValid: boolean = validEmails.includes(email) || (isEmailFromPrivateCompany(email) && validEmails.some((validEmail) => validEmail && isEmailSameDomain(email, validEmail)))
if (isValid) {
- return true
+ return { isValid: true, validator: "bonnes boites ou referentiel opco" }
} else {
const balControl = await validationOrganisation(siret, email)
- return balControl.is_valid
+ return { isValid: balControl.is_valid, validator: "BAL" }
}
}
@@ -661,7 +663,7 @@ export const validateCreationEntrepriseFromCfa = async ({ siret, cfa_delegated_s
}
}
-export const getEntrepriseDataFromSiret = async ({ siret, cfa_delegated_siret }: { siret: string; cfa_delegated_siret?: string }) => {
+export const getEntrepriseDataFromSiret = async ({ siret, type }: { siret: string; type: "CFA" | "ENTREPRISE" }) => {
const result = await getEtablissementFromGouvSafe(siret)
if (!result) {
return errorFactory("Le numéro siret est invalide.")
@@ -679,7 +681,7 @@ export const getEntrepriseDataFromSiret = async ({ siret, cfa_delegated_siret }:
return errorFactory("Cette entreprise est considérée comme fermée.", BusinessErrorCodes.CLOSED)
}
// Check if a CFA already has the company as partenaire
- if (!cfa_delegated_siret) {
+ if (type === ENTREPRISE) {
// Allow cfa to add themselves as a company
if (activite_principale.code.startsWith("85")) {
return errorFactory("Le numéro siret n'est pas référencé comme une entreprise.", BusinessErrorCodes.IS_CFA)
@@ -687,7 +689,7 @@ export const getEntrepriseDataFromSiret = async ({ siret, cfa_delegated_siret }:
}
const entrepriseData = formatEntrepriseData(result.data)
if (!entrepriseData.establishment_raison_sociale) {
- throw Boom.internal("pas de raison sociale trouvée", { siret, cfa_delegated_siret, entrepriseData, apiData: result.data })
+ throw Boom.internal("pas de raison sociale trouvée", { siret, type, entrepriseData, apiData: result.data })
}
const numeroEtRue = entrepriseData.address_detail.acheminement_postal.l4
const codePostalEtVille = entrepriseData.address_detail.acheminement_postal.l6
@@ -695,10 +697,21 @@ export const getEntrepriseDataFromSiret = async ({ siret, cfa_delegated_siret }:
return { ...entrepriseData, geo_coordinates: `${latitude},${longitude}`, geopoint: { type: "Point", coordinates: [longitude, latitude] as [number, number] } }
}
+const isCfaCreationValid = async (siret: string): Promise => {
+ const cfa = await Cfa.findOne({ siret }).lean()
+ if (!cfa) return true
+ const role = await RoleManagement.findOne({ authorized_type: AccessEntityType.CFA, authorized_id: cfa._id.toString() }).lean()
+ if (!role) return true
+ if (getLastStatusEvent(role.status)?.status !== AccessStatus.DENIED) {
+ return false
+ }
+ return true
+}
+
export const getOrganismeDeFormationDataFromSiret = async (siret: string, shouldValidate = true) => {
if (shouldValidate) {
- const cfaUserRecruteurOpt = await getEtablissement({ establishment_siret: siret, type: CFA })
- if (cfaUserRecruteurOpt) {
+ const isValid = await isCfaCreationValid(siret)
+ if (!isValid) {
throw Boom.forbidden("Ce numéro siret est déjà associé à un compte utilisateur.", { reason: BusinessErrorCodes.ALREADY_EXISTS })
}
}
@@ -749,18 +762,19 @@ export const entrepriseOnboardingWorkflow = {
}: {
isUserValidated?: boolean
} = {}
- ): Promise => {
+ ): Promise => {
+ origin = origin ?? ""
const cfaErrorOpt = await validateCreationEntrepriseFromCfa({ siret, cfa_delegated_siret })
if (cfaErrorOpt) return cfaErrorOpt
const formatedEmail = email.toLocaleLowerCase()
- const userRecruteurOpt = await getUser({ email: formatedEmail })
+ const userRecruteurOpt = await getUserRecruteurByEmail(formatedEmail)
if (userRecruteurOpt) {
return errorFactory("L'adresse mail est déjà associée à un compte La bonne alternance.", BusinessErrorCodes.ALREADY_EXISTS)
}
let entrepriseData: Partial
let hasSiretError = false
try {
- const siretResponse = await getEntrepriseDataFromSiret({ siret, cfa_delegated_siret })
+ const siretResponse = await getEntrepriseDataFromSiret({ siret, type: cfa_delegated_siret ? CFA : ENTREPRISE })
if ("error" in siretResponse) {
return siretResponse
} else {
@@ -773,24 +787,49 @@ export const entrepriseOnboardingWorkflow = {
}
const contactInfos = { first_name, last_name, phone, opco, idcc, origin }
const savedData = { ...entrepriseData, ...contactInfos, email: formatedEmail }
- const formulaireInfo = await createFormulaire({
+ const creationResult = await createOrganizationUser({
...savedData,
- status: RECRUITER_STATUS.ACTIF,
- jobs: [],
- cfa_delegated_siret,
+ type: ENTREPRISE,
+ is_email_checked: false,
+ is_qualiopi: false,
})
- const formulaireId = formulaireInfo.establishment_id
- let newEntreprise: IUserRecruteur = await createUser({ ...savedData, establishment_id: formulaireId, type: ENTREPRISE, is_email_checked: false, is_qualiopi: false })
+ const formulaireInfo = await createFormulaire(
+ {
+ ...savedData,
+ status: RECRUITER_STATUS.ACTIF,
+ jobs: [],
+ cfa_delegated_siret,
+ },
+ creationResult.user._id.toString()
+ )
+ let validated = false
if (hasSiretError) {
- newEntreprise = await setUserInError(newEntreprise._id, "Erreur lors de l'appel à l'API SIRET")
- } else if (isUserValidated) {
- newEntreprise = await autoValidateUser(newEntreprise._id)
+ await setEntrepriseInError(creationResult.organization._id, "Erreur lors de l'appel à l'API SIRET")
} else {
- const balValidationResult = await autoValidateCompany(newEntreprise)
- newEntreprise = balValidationResult.userRecruteur
+ await setEntrepriseValid(creationResult.organization._id)
+ if (isUserValidated) {
+ await modifyPermissionToUser(
+ {
+ user_id: creationResult.user._id,
+ authorized_id: creationResult.organization._id.toString(),
+ authorized_type: creationResult.type === ENTREPRISE ? AccessEntityType.ENTREPRISE : AccessEntityType.CFA,
+ origin,
+ },
+ {
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ status: AccessStatus.GRANTED,
+ reason: "création par clef API",
+ }
+ )
+ validated = true
+ } else {
+ const result = await autoValidateUserRoleOnCompany(creationResult, origin)
+ validated = result.validated
+ }
}
- return { formulaire: formulaireInfo, user: newEntreprise }
+
+ return { formulaire: formulaireInfo, user: creationResult.user, validated }
},
createFromCFA: async ({
email,
@@ -802,6 +841,7 @@ export const entrepriseOnboardingWorkflow = {
origin,
opco,
idcc,
+ managedBy,
}: {
siret: string
last_name: string
@@ -812,6 +852,7 @@ export const entrepriseOnboardingWorkflow = {
origin?: string | null
opco?: string
idcc?: string | null
+ managedBy: string
}) => {
const cfaErrorOpt = await validateCreationEntrepriseFromCfa({ siret, cfa_delegated_siret })
if (cfaErrorOpt) return cfaErrorOpt
@@ -819,7 +860,7 @@ export const entrepriseOnboardingWorkflow = {
let entrepriseData: Partial
let siretCallInError = false
try {
- const siretResponse = await getEntrepriseDataFromSiret({ siret, cfa_delegated_siret })
+ const siretResponse = await getEntrepriseDataFromSiret({ siret, type: cfa_delegated_siret ? CFA : ENTREPRISE })
if ("error" in siretResponse) {
return siretResponse
} else {
@@ -832,22 +873,25 @@ export const entrepriseOnboardingWorkflow = {
}
const contactInfos = { first_name, last_name, phone, origin }
const savedData = { ...entrepriseData, ...contactInfos, email: formatedEmail }
- const formulaireInfo = await createFormulaire({
- ...savedData,
- status: siretCallInError ? RECRUITER_STATUS.EN_ATTENTE_VALIDATION : RECRUITER_STATUS.ACTIF,
- jobs: [],
- cfa_delegated_siret,
- is_delegated: true,
- origin,
- opco,
- idcc,
- })
+ const formulaireInfo = await createFormulaire(
+ {
+ ...savedData,
+ status: siretCallInError ? RECRUITER_STATUS.EN_ATTENTE_VALIDATION : RECRUITER_STATUS.ACTIF,
+ jobs: [],
+ cfa_delegated_siret,
+ is_delegated: true,
+ origin,
+ opco,
+ idcc,
+ },
+ managedBy
+ )
return formulaireInfo
},
}
-export const sendUserConfirmationEmail = async (user: IUserRecruteur) => {
- const url = createValidationMagicLink(user)
+export const sendUserConfirmationEmail = async (user: IUser2) => {
+ const url = createValidationMagicLink(user2ToUserForToken(user))
await mailer.sendEmail({
to: user.email,
subject: "Confirmez votre adresse mail",
@@ -863,17 +907,21 @@ export const sendUserConfirmationEmail = async (user: IUserRecruteur) => {
})
}
-export const sendEmailConfirmationEntreprise = async (user: IUserRecruteur, recruteur: IRecruiter) => {
- const userStatus = getUserStatus(user.status)
- if (userStatus === ETAT_UTILISATEUR.ERROR || user.is_email_checked) {
+export const sendEmailConfirmationEntreprise = async (user: IUser2, recruteur: IRecruiter, accessStatus: AccessStatus | null, entrepriseStatus: EntrepriseStatus | null) => {
+ if (
+ entrepriseStatus !== EntrepriseStatus.VALIDE ||
+ isUserEmailChecked(user) ||
+ !accessStatus ||
+ ![AccessStatus.GRANTED, AccessStatus.AWAITING_VALIDATION].includes(accessStatus)
+ ) {
return
}
- const isUserAwaiting = userStatus !== ETAT_UTILISATEUR.VALIDE
+ const isUserAwaiting = accessStatus === AccessStatus.AWAITING_VALIDATION
const { jobs, is_delegated, email } = recruteur
const offre = jobs.at(0)
if (jobs.length === 1 && offre && is_delegated === false) {
// Get user account validation link
- const url = createValidationMagicLink(user)
+ const url = createValidationMagicLink(user2ToUserForToken(user))
await mailer.sendEmail({
to: email,
subject: "Confirmez votre adresse mail",
@@ -896,7 +944,11 @@ export const sendEmailConfirmationEntreprise = async (user: IUserRecruteur, recr
},
})
} else {
- await sendUserConfirmationEmail(user)
+ const user2 = await User2.findOne({ _id: user._id.toString() }).lean()
+ if (!user2) {
+ throw Boom.internal(`could not find user with id=${user._id}`)
+ }
+ await sendUserConfirmationEmail(user2)
}
}
diff --git a/server/src/services/formation.service.ts b/server/src/services/formation.service.ts
index 564d1a2137..a71926b4d2 100644
--- a/server/src/services/formation.service.ts
+++ b/server/src/services/formation.service.ts
@@ -31,10 +31,36 @@ const getDiplomaIndexName = (value) => {
return value ? diplomaMap[value[0]] : ""
}
+const minimalDataMongoFields = {
+ cle_ministere_educatif: 1,
+ code_commune_insee: 1,
+ code_postal: 1,
+ etablissement_formateur_siret: 1,
+ etablissement_formateur_enseigne: 1,
+ etablissement_formateur_entreprise_raison_sociale: 1,
+ etablissement_formateur_adresse: 1,
+ etablissement_formateur_complement_adresse: 1,
+ etablissement_formateur_localite: 1,
+ etablissement_formateur_code_postal: 1,
+ etablissement_formateur_cedex: 1,
+ etablissement_gestionnaire_adresse: 1,
+ etablissement_gestionnaire_complement_adresse: 1,
+ etablissement_gestionnaire_localite: 1,
+ etablissement_gestionnaire_code_postal: 1,
+ etablissement_gestionnaire_cedex: 1,
+ etablissement_gestionnaire_entreprise_raison_sociale: 1,
+ etablissement_gestionnaire_siret: 1,
+ intitule_court: 1,
+ intitule_long: 1,
+ lieu_formation_adresse: 1,
+ lieu_formation_geo_coordonnees: 1,
+ localite: 1,
+}
+
/**
* Récupère les formations matchant les critères en paramètre depuis la mongo
*/
-export const getFormations = async ({
+const getFormations = async ({
romes,
romeDomain,
coords,
@@ -62,10 +88,6 @@ export const getFormations = async ({
const now = new Date()
- // tags contient les années de démarrage des sessions. règle métier : année en cours, année à venir et année passée OU année + 2 selon qu'on
- // est en septembre ou plus tôt dans l'année
- const tags = [now.getFullYear(), now.getFullYear() + 1, now.getFullYear() + (now.getMonth() < 8 ? -1 : 2)]
-
const query: any = {}
if (romes) {
@@ -78,23 +100,26 @@ export const getFormations = async ({
}
}
+ // tags contient les années de démarrage des sessions. règle métier : année en cours, année à venir et année passée OU année + 2 selon qu'on
+ // est en septembre ou plus tôt dans l'année
+ const tags = [now.getFullYear(), now.getFullYear() + 1, now.getFullYear() + (now.getMonth() < 8 ? -1 : 2)]
query.tags = { $in: tags.map((tag) => tag.toString()) }
if (diploma) {
query.niveau = getDiplomaIndexName(diploma)
}
- let formations: any[] = []
-
const stages: any[] = []
if (isMinimalData) {
- stages.push({ $project: { objectif: 0, contenu: 0 } })
- // TODO réduire encore
+ stages.push({
+ $project: minimalDataMongoFields,
+ })
} else if (options.indexOf("with_description") < 0) {
stages.push({ $project: { objectif: 0, contenu: 0 } })
}
+ let formations: any[] = []
if (coords) {
stages.push({
$limit: limit,
@@ -124,21 +149,6 @@ export const getFormations = async ({
return formations
}
-/**
- * Retourne une formation provenant de la collection des formationsCatalogues
- * @param {string} id l'identifiant de la formation
- * @returns {Promise}
- */
-const getFormation = async ({ id }: { id: string }) => FormationCatalogue.findOne({ cle_ministere_educatif: id })
-
-/**
- * Retourne une formation du catalogue transformée en LbaItem
- */
-const getOneFormationFromId = async ({ id }: { id: string }): Promise => {
- const formation = await getFormation({ id })
- return formation ? [transformFormation(formation)] : []
-}
-
/**
* Récupère les formations matchant les critères en paramètre sur une région ou un département donné
* @param {string[]} romes un tableau de codes ROME
@@ -187,7 +197,7 @@ const getRegionFormations = async ({
$regex: new RegExp(`^${departement}`, "i"),
}
} else if (region) {
- query.code_postal = getRegionQueryFragment(region)
+ query.code_postal = { $in: regionCodeToDepartmentList[region].map((departement) => new RegExp(`^${departement}`)) }
}
const now = new Date()
@@ -198,8 +208,6 @@ const getRegionFormations = async ({
query.niveau = getDiplomaIndexName(diploma)
}
- let formations: any[] = []
-
const stages: any[] = []
if (options.indexOf("with_description") < 0) {
@@ -215,8 +223,7 @@ const getRegionFormations = async ({
},
})
- formations = await FormationCatalogue.aggregate(stages)
-
+ const formations: any[] = await FormationCatalogue.aggregate(stages)
if (formations.length === 0 && !caller) {
await notifyToSlack({ subject: "FORMATION", message: `Aucune formation par région trouvée pour les romes ${romes} ou le domaine ${romeDomain}.` })
}
@@ -310,8 +317,9 @@ export const deduplicateFormations = (formations: IFormationCatalogue[]): IForma
const transformFormations = (rawFormations: IFormationCatalogue[], isMinimalData: boolean): ILbaItemFormation[] => {
const formations: ILbaItemFormation[] = []
if (rawFormations.length) {
+ const transformFct = isMinimalData ? transformFormationWithMinimalData : transformFormation
for (let i = 0; i < rawFormations.length; ++i) {
- formations.push(isMinimalData ? transformFormationWithMinimalData(rawFormations[i]) : transformFormation(rawFormations[i]))
+ formations.push(transformFct(rawFormations[i]))
}
}
@@ -571,21 +579,10 @@ export const getFormationsQuery = async ({
/**
* Retourne une formation identifiée par son id
*/
-export const getFormationQuery = async ({ id, caller }: { id: string; caller?: string }): Promise => {
- try {
- const formation = await getOneFormationFromId({ id })
- return {
- results: formation,
- }
- } catch (err) {
- sentryCaptureException(err)
-
- if (caller) {
- trackApiCall({ caller, api_path: "formationV1/formation", response: "Error" })
- }
-
- return { error: "internal_error" }
- }
+export const getFormationQuery = async ({ id }: { id: string }): Promise<{ results: ILbaItemFormation[] }> => {
+ const formation = await FormationCatalogue.findOne({ cle_ministere_educatif: id })
+ const formations = formation ? [transformFormation(formation)] : []
+ return { results: formations }
}
/**
@@ -642,17 +639,6 @@ export const getFormationsParRegionQuery = async ({
}
}
-/**
- * retourne le morceau de requête mongo correspondant à un filtrage sur une région donné
- * @param {string} region le code de la région
- * @returns {object}
- */
-const getRegionQueryFragment = (region: string): object => {
- return {
- $in: regionCodeToDepartmentList[region].map((departement) => new RegExp(`^${departement}`)),
- }
-}
-
/**
* tri alphabétique de formations sur le title (primaire) ou le company.name (secondaire )
* lorsque les formations ne sont pas déjà triées sur la distance par rapport à un point de recherche
diff --git a/server/src/services/formulaire.service.ts b/server/src/services/formulaire.service.ts
index b415d16308..8e1700cd56 100644
--- a/server/src/services/formulaire.service.ts
+++ b/server/src/services/formulaire.service.ts
@@ -2,21 +2,26 @@ import Boom from "boom"
import type { ObjectId as ObjectIdType } from "mongodb"
import type { FilterQuery, ModelUpdateOptions, UpdateQuery } from "mongoose"
import { IDelegation, IJob, IJobWritable, IRecruiter, IUserRecruteur, JOB_STATUS } from "shared"
-import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur"
+import { RECRUITER_STATUS } from "shared/constants/recruteur"
+import { EntrepriseStatus, IEntreprise } from "shared/models/entreprise.model"
+import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model"
+import { IUser2 } from "shared/models/user2.model"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
import { getStaticFilePath } from "@/common/utils/getStaticFilePath"
-import { Recruiter, UnsubscribeOF } from "../common/model/index"
+import { Cfa, Entreprise, Recruiter, RoleManagement, UnsubscribeOF } from "../common/model/index"
import { asyncForEach } from "../common/utils/asyncUtils"
import config from "../config"
+import { getUser2ManagingOffer } from "./application.service"
import { createCfaUnsubscribeToken, createViewDelegationLink } from "./appLinks.service"
import { getCatalogueEtablissements, getCatalogueFormations } from "./catalogue.service"
import dayjs from "./dayjs.service"
-import { getEtablissement, sendEmailConfirmationEntreprise } from "./etablissement.service"
+import { sendEmailConfirmationEntreprise } from "./etablissement.service"
import mailer, { sanitizeForEmail } from "./mailer.service"
+import { getComputedUserAccess, getGrantedRoles } from "./roleManagement.service"
import { getRomeDetailsFromDB } from "./rome.service"
-import { getUser, getUserStatus } from "./userRecruteur.service"
export interface IOffreExtended extends IJob {
candidatures: number
@@ -27,12 +32,12 @@ export interface IOffreExtended extends IJob {
/**
* @description get formulaire by offer id
*/
-export const getOffreAvecInfoMandataire = async (id: string | ObjectIdType): Promise<{ recruiter: IRecruiter; job: IJob } | null> => {
- const recruiterOpt = await getOffre(id)
+export const getOffreAvecInfoMandataire = async (jobId: string | ObjectIdType): Promise<{ recruiter: IRecruiter; job: IJob } | null> => {
+ const recruiterOpt = await getOffre(jobId)
if (!recruiterOpt) {
return null
}
- const job = recruiterOpt.jobs.find((x) => x._id.toString() === id.toString())
+ const job = recruiterOpt.jobs.find((x) => x._id.toString() === jobId.toString())
if (!job) {
return null
}
@@ -40,14 +45,15 @@ export const getOffreAvecInfoMandataire = async (id: string | ObjectIdType): Pro
if (recruiterOpt.is_delegated && recruiterOpt.address) {
const { cfa_delegated_siret } = recruiterOpt
if (cfa_delegated_siret) {
- const cfa = await getEtablissement({ establishment_siret: cfa_delegated_siret })
-
+ const cfa = await Cfa.findOne({ siret: cfa_delegated_siret }).lean()
if (cfa) {
- recruiterOpt.phone = cfa.phone
- recruiterOpt.email = cfa.email
- recruiterOpt.last_name = cfa.last_name
- recruiterOpt.first_name = cfa.first_name
- recruiterOpt.establishment_raison_sociale = cfa.establishment_raison_sociale
+ const cfaUser = await getUser2ManagingOffer(getJobFromRecruiter(recruiterOpt, jobId.toString()))
+
+ recruiterOpt.phone = cfaUser.phone
+ recruiterOpt.email = cfaUser.email
+ recruiterOpt.last_name = cfaUser.last_name
+ recruiterOpt.first_name = cfaUser.first_name
+ recruiterOpt.establishment_raison_sociale = cfa.raison_sociale
recruiterOpt.address = cfa.address
return { recruiter: recruiterOpt, job }
}
@@ -78,52 +84,81 @@ export const getFormulaires = async (query: FilterQuery, select: obj
}
}
+const isAuthorizedToPublishJob = async ({ userId, entrepriseId }: { userId: ObjectIdType; entrepriseId: ObjectIdType }) => {
+ const access = getComputedUserAccess(userId.toString(), await getGrantedRoles(userId.toString()))
+ return access.admin || access.entreprises.includes(entrepriseId.toString())
+}
+
/**
* @description Create job offer for formulaire
*/
-export const createJob = async ({ job, establishment_id }: { job: IJobWritable; establishment_id: string }): Promise => {
- // get user data
- const user = await getUser({ establishment_id })
- const userStatus: ETAT_UTILISATEUR | null = (user ? getUserStatus(user.status) : null) ?? null
- const isUserAwaiting = userStatus !== ETAT_UTILISATEUR.VALIDE
-
- const jobPartial: Partial = job
- jobPartial.job_status = user && isUserAwaiting ? JOB_STATUS.EN_ATTENTE : JOB_STATUS.ACTIVE
+export const createJob = async ({ job, establishment_id, user }: { job: IJobWritable; establishment_id: string; user: IUser2 }): Promise => {
+ const userId = user._id
+ const recruiter = await Recruiter.findOne({ establishment_id: establishment_id }).lean()
+ if (!recruiter) {
+ throw Boom.internal(`recruiter with establishment_id=${establishment_id} not found`)
+ }
+ const { is_delegated, cfa_delegated_siret } = recruiter
+ const organization = await (cfa_delegated_siret ? Cfa.findOne({ siret: cfa_delegated_siret }).lean() : Entreprise.findOne({ siret: recruiter.establishment_siret }).lean())
+ if (!organization) {
+ throw Boom.internal(`inattendu : impossible retrouver l'organisation pour establishment_id=${establishment_id}`)
+ }
+ let isOrganizationValid = false
+ let entrepriseStatus: EntrepriseStatus | null = null
+ if (cfa_delegated_siret) {
+ isOrganizationValid = true
+ } else if ("status" in organization) {
+ entrepriseStatus = getLastStatusEvent((organization as IEntreprise).status)?.status ?? null
+ isOrganizationValid = entrepriseStatus === EntrepriseStatus.VALIDE && (await isAuthorizedToPublishJob({ userId, entrepriseId: organization._id }))
+ }
+ const isJobActive = isOrganizationValid
+
+ const newJobStatus = isJobActive ? JOB_STATUS.ACTIVE : JOB_STATUS.EN_ATTENTE
// get user activation state if not managed by a CFA
- const codeRome = job.rome_code[0]
+ const codeRome = job.rome_code.at(0)
+ if (!codeRome) {
+ throw Boom.internal(`inattendu : pas de code rome pour une création d'offre pour le recruiter id=${establishment_id}`)
+ }
const romeData = await getRomeDetailsFromDB(codeRome)
if (!romeData) {
throw Boom.internal(`could not find rome infos for rome=${codeRome}`)
}
const creationDate = new Date()
- const { job_start_date = creationDate } = job
+ const { job_start_date } = job
const updatedJob: Partial = Object.assign(job, {
+ job_status: newJobStatus,
job_start_date,
rome_detail: romeData.fiche_metier,
job_creation_date: creationDate,
job_expiration_date: addExpirationPeriod(creationDate).toDate(),
job_update_date: creationDate,
+ managed_by: userId,
})
// insert job
const updatedFormulaire = await createOffre(establishment_id, updatedJob)
- const { is_delegated, cfa_delegated_siret, jobs } = updatedFormulaire
+ const { jobs } = updatedFormulaire
const createdJob = jobs.at(jobs.length - 1)
if (!createdJob) {
throw Boom.internal("unexpected: no job found after job creation")
}
// if first offer creation for an Entreprise, send specific mail
- if (jobs.length === 1 && is_delegated === false && user) {
- await sendEmailConfirmationEntreprise(user, updatedFormulaire)
+ if (jobs.length === 1 && is_delegated === false) {
+ if (!entrepriseStatus) {
+ throw Boom.internal(`inattendu : pas de status pour l'entreprise pour establishment_id=${establishment_id}`)
+ }
+ const role = await RoleManagement.findOne({ user_id: userId, authorized_type: AccessEntityType.ENTREPRISE, authorized_id: organization._id.toString() }).lean()
+ const roleStatus = getLastStatusEvent(role?.status)?.status ?? null
+ await sendEmailConfirmationEntreprise(user, updatedFormulaire, roleStatus, entrepriseStatus)
return updatedFormulaire
}
- let contactCFA: IUserRecruteur | null = null
+ let contactCFA: IUser2 | null = null
if (is_delegated) {
if (!cfa_delegated_siret) {
throw Boom.internal(`unexpected: could not find user recruteur CFA that created the job`)
}
// get CFA informations if formulaire is handled by a CFA
- contactCFA = await getUser({ establishment_siret: cfa_delegated_siret })
+ contactCFA = await getUser2ManagingOffer(createdJob)
if (!contactCFA) {
throw Boom.internal(`unexpected: could not find user recruteur CFA that created the job`)
}
@@ -136,29 +171,22 @@ export const createJob = async ({ job, establishment_id }: { job: IJobWritable;
* Create job delegations
*/
export const createJobDelegations = async ({ jobId, etablissementCatalogueIds }: { jobId: IJob["_id"] | string; etablissementCatalogueIds: string[] }): Promise => {
- const offreDocument = await getOffre(jobId)
- if (!offreDocument) {
+ const recruiter = await getOffre(jobId)
+ if (!recruiter) {
throw Boom.internal("Offre not found", { jobId, etablissementCatalogueIds })
}
- const userDocument = await getUser({ establishment_id: offreDocument.establishment_id })
- if (!userDocument) {
- throw Boom.internal("User not found", { jobId, etablissementCatalogueIds })
- }
- if (!userDocument.status) {
- throw Boom.internal("User is missing status object", { jobId, etablissementCatalogueIds })
- }
- const userState = userDocument.status.pop()
-
- const offre = offreDocument.jobs.find((job) => job._id.toString() === jobId.toString())
-
- if (!offre) {
- throw Boom.internal("Offre not found", { jobId, etablissementCatalogueIds })
+ const offre = getJobFromRecruiter(recruiter, jobId.toString())
+ const managingUser = await getUser2ManagingOffer(offre)
+ const entreprise = await Entreprise.findOne({ siret: recruiter.establishment_siret }).lean()
+ let shouldSentMailToCfa = false
+ if (entreprise) {
+ const role = await RoleManagement.findOne({ user_id: managingUser._id, authorized_id: entreprise._id.toString(), authorized_type: AccessEntityType.ENTREPRISE }).lean()
+ if (role && getLastStatusEvent(role.status)?.status === AccessStatus.GRANTED) {
+ shouldSentMailToCfa = true
+ }
}
-
const { etablissements } = await getCatalogueEtablissements({ _id: { $in: etablissementCatalogueIds } }, { _id: 1 })
-
const delegations: IDelegation[] = []
-
const promises = etablissements.map(async (etablissement) => {
const formations = await getCatalogueFormations(
{
@@ -185,8 +213,8 @@ export const createJobDelegations = async ({ jobId, etablissementCatalogueIds }:
delegations.push({ siret_code, email })
- if (userState?.status === ETAT_UTILISATEUR.VALIDE) {
- await sendDelegationMailToCFA(email, offre, offreDocument, siret_code)
+ if (shouldSentMailToCfa) {
+ await sendDelegationMailToCFA(email, offre, recruiter, siret_code)
}
})
@@ -220,8 +248,8 @@ export const getFormulaire = async (query: FilterQuery): Promise}
*/
-export const createFormulaire = async (payload: Partial>): Promise => {
- const recruiter = await Recruiter.create(payload)
+export const createFormulaire = async (payload: Partial>, managedBy: string): Promise => {
+ const recruiter = await Recruiter.create({ ...payload, managed_by: managedBy })
return recruiter.toObject()
}
@@ -277,8 +305,8 @@ export const archiveFormulaire = async (id: IRecruiter["establishment_id"]): Pro
* @param {IRecruiter["establishment_id"]} establishment_id
* @returns {Promise}
*/
-export const reactivateRecruiter = async (id: IRecruiter["establishment_id"]): Promise => {
- const recruiter = await Recruiter.findOne({ establishment_id: id })
+export const reactivateRecruiter = async (id: IRecruiter["_id"]): Promise => {
+ const recruiter = await Recruiter.findOne({ _id: id })
if (!recruiter) {
throw Boom.internal("Recruiter not found")
}
@@ -541,7 +569,7 @@ export async function sendDelegationMailToCFA(email: string, offre: IJob, recrui
})
}
-export async function sendMailNouvelleOffre(recruiter: IRecruiter, job: IJob, contactCFA?: IUserRecruteur) {
+export async function sendMailNouvelleOffre(recruiter: IRecruiter, job: IJob, contactCFA?: IUser2) {
const isRecruteurAwaiting = recruiter.status === RECRUITER_STATUS.EN_ATTENTE_VALIDATION
if (isRecruteurAwaiting) {
return
@@ -575,3 +603,23 @@ export async function sendMailNouvelleOffre(recruiter: IRecruiter, job: IJob, co
export function addExpirationPeriod(fromDate: Date | dayjs.Dayjs): dayjs.Dayjs {
return dayjs(fromDate).add(2, "months")
}
+
+export const getJobFromRecruiter = (recruiter: IRecruiter, jobId: string): IJob => {
+ const job = recruiter.jobs.find((job) => job._id.toString() === jobId)
+ if (!job) {
+ throw new Error(`could not find job with id=${jobId} in recruiter with id=${recruiter._id}`)
+ }
+ return job
+}
+
+export const getFormulaireFromUserId = async (userId: string) => {
+ return Recruiter.findOne({ managed_by: userId }).lean()
+}
+
+export const getFormulaireFromUserIdOrError = async (userId: string) => {
+ const formulaire = await getFormulaireFromUserId(userId)
+ if (!formulaire) {
+ throw Boom.internal(`inattendu : formulaire non trouvé`, { userId })
+ }
+ return formulaire
+}
diff --git a/server/src/services/jobOpportunity.service.ts b/server/src/services/jobOpportunity.service.ts
index 3845b629e4..101250d9a3 100644
--- a/server/src/services/jobOpportunity.service.ts
+++ b/server/src/services/jobOpportunity.service.ts
@@ -7,7 +7,7 @@ import { getSomeFtJobs } from "./ftjob.service"
import { TJobSearchQuery, TLbaItemResult } from "./jobOpportunity.service.types"
import { getSomeCompanies } from "./lbacompany.service"
import { ILbaItemLbaCompany, ILbaItemLbaJob, ILbaItemFtJob } from "./lbaitem.shared.service.types"
-import { getLbaJobs } from "./lbajob.service"
+import { getLbaJobs, incrementLbaJobsViewCount } from "./lbajob.service"
import { jobsQueryValidator } from "./queryValidator.service"
/**
@@ -159,6 +159,7 @@ export const getJobsQuery = async (
if ("matchas" in result && result.matchas && "results" in result.matchas) {
job_count += result.matchas.results.length
+ await incrementLbaJobsViewCount(result.matchas.results.flatMap((job) => (job?.id ? [job.id] : [])))
}
if (query.caller) {
diff --git a/server/src/services/lbajob.service.ts b/server/src/services/lbajob.service.ts
index 9f3b0a49dc..17f88100fc 100644
--- a/server/src/services/lbajob.service.ts
+++ b/server/src/services/lbajob.service.ts
@@ -4,7 +4,7 @@ import { IJob, IRecruiter, JOB_STATUS } from "shared"
import { LBA_ITEM_TYPE_OLD } from "shared/constants/lbaitem"
import { RECRUITER_STATUS } from "shared/constants/recruteur"
-import { Recruiter } from "@/common/model"
+import { Cfa, Recruiter } from "@/common/model"
import { db } from "@/common/mongodb"
import { encryptMailWithIV } from "../common/utils/encryptString"
@@ -13,9 +13,8 @@ import { roundDistance } from "../common/utils/geolib"
import { trackApiCall } from "../common/utils/sendTrackingEvent"
import { sentryCaptureException } from "../common/utils/sentryUtils"
-import { IApplicationCount, getApplicationByJobCount } from "./application.service"
+import { IApplicationCount, getApplicationByJobCount, getUser2ManagingOffer } from "./application.service"
import { NIVEAUX_POUR_LBA } from "./constant.service"
-import { getEtablissement } from "./etablissement.service"
import { getOffreAvecInfoMandataire } from "./formulaire.service"
import { ILbaItemLbaJob } from "./lbaitem.shared.service.types"
import { filterJobsByOpco } from "./opco.service"
@@ -69,34 +68,34 @@ export const getJobs = async ({ distance, lat, lon, romes, niveau }: { distance:
},
})
- const jobs: IRecruiter[] = await Recruiter.aggregate(stages)
+ const recruiters: IRecruiter[] = await Recruiter.aggregate(stages)
const filteredJobs = await Promise.all(
- jobs.map(async (x) => {
+ recruiters.map(async (recruiter) => {
const jobs: any[] = []
- if (x.is_delegated) {
- const cfa = await getEtablissement({ establishment_siret: x.cfa_delegated_siret })
-
- x.phone = cfa?.phone
- x.email = cfa?.email || ""
- x.last_name = cfa?.last_name
- x.first_name = cfa?.first_name
- x.establishment_raison_sociale = cfa?.establishment_raison_sociale
- x.address = cfa?.address
+ if (recruiter.is_delegated && recruiter.cfa_delegated_siret) {
+ const cfa = await Cfa.findOne({ siret: recruiter.cfa_delegated_siret })
+ const cfaUser = await getUser2ManagingOffer(recruiter.jobs[0])
+ recruiter.phone = cfaUser.phone
+ recruiter.email = cfaUser.email
+ recruiter.last_name = cfaUser.last_name
+ recruiter.first_name = cfaUser.first_name
+ recruiter.establishment_raison_sociale = cfa?.raison_sociale
+ recruiter.address = cfa?.address
}
- x.jobs.forEach((o) => {
- if (romes.some((item) => o.rome_code.includes(item)) && o.job_status === JOB_STATUS.ACTIVE) {
- o.rome_label = o.rome_appellation_label ?? o.rome_label
- if (!niveau || NIVEAUX_POUR_LBA["INDIFFERENT"] === o.job_level_label || niveau === o.job_level_label) {
- jobs.push(o)
+ recruiter.jobs.forEach((job) => {
+ if (romes.some((item) => job.rome_code.includes(item)) && job.job_status === JOB_STATUS.ACTIVE) {
+ job.rome_label = job.rome_appellation_label ?? job.rome_label
+ if (!niveau || NIVEAUX_POUR_LBA["INDIFFERENT"] === job.job_level_label || niveau === job.job_level_label) {
+ jobs.push(job)
}
}
})
- x.jobs = jobs
- return x
+ recruiter.jobs = jobs
+ return recruiter
})
)
@@ -373,6 +372,7 @@ function transformLbaJobWithMinimalData({ recruiter, applicationCountByJob }: {
// si mandataire contient les données du CFA
siret: recruiter.establishment_siret,
name: recruiter.establishment_enseigne || recruiter.establishment_raison_sociale || "Enseigne inconnue",
+ mandataire: recruiter.is_delegated,
},
job: {
creationDate: offre.job_creation_date ? new Date(offre.job_creation_date) : null,
@@ -434,8 +434,8 @@ export const addOffreDetailView = async (jobId: IJob["_id"] | string) => {
/**
* @description Incrémente les compteurs de vue d'un ensemble d'offres lba
*/
-export const incrementLbaJobsViewCount = async (lbaJobs) => {
- const ids = lbaJobs.results.map((job) => new ObjectId(job.id))
+export const incrementLbaJobsViewCount = async (jobIds: string[]) => {
+ const ids = jobIds.map((id) => new ObjectId(id))
try {
await db.collection("recruiters").updateMany(
{ "jobs._id": { $in: ids } },
diff --git a/server/src/services/login.service.ts b/server/src/services/login.service.ts
index 0ef6dd74eb..0fd620f11b 100644
--- a/server/src/services/login.service.ts
+++ b/server/src/services/login.service.ts
@@ -1,27 +1,40 @@
import Boom from "boom"
-import { IUserRecruteur, assertUnreachable } from "shared"
-import { ETAT_UTILISATEUR } from "shared/constants/recruteur"
+import { assertUnreachable } from "shared"
+import { EntrepriseStatus } from "shared/models/entreprise.model"
+import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model"
+import { IUser2, UserEventType } from "shared/models/user2.model"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
-import { getUserStatus } from "./userRecruteur.service"
+import { Entreprise, RoleManagement } from "@/common/model"
-export const controlUserState = (status: IUserRecruteur["status"]): { error: boolean; reason?: string } => {
- const currentState = getUserStatus(status)
- switch (currentState) {
- case ETAT_UTILISATEUR.ATTENTE:
- case ETAT_UTILISATEUR.ERROR:
- return { error: true, reason: "VALIDATION" }
-
- case ETAT_UTILISATEUR.DESACTIVE:
+export const controlUserState = async (user: IUser2): Promise<{ error: boolean; reason?: string }> => {
+ const status = getLastStatusEvent(user.status)?.status
+ switch (status) {
+ case UserEventType.DESACTIVE:
return { error: true, reason: "DISABLED" }
-
- case ETAT_UTILISATEUR.VALIDE:
- return { error: false }
-
+ case UserEventType.VALIDATION_EMAIL:
+ case UserEventType.ACTIF: {
+ const roles = await RoleManagement.find({ user_id: user._id.toString() }).lean()
+ const rolesWithAccess = roles.filter((role) => getLastStatusEvent(role.status)?.status === AccessStatus.GRANTED)
+ const cfaOpcoOrAdminRoles = rolesWithAccess.filter((role) => [AccessEntityType.ADMIN, AccessEntityType.OPCO, AccessEntityType.CFA].includes(role.authorized_type))
+ if (cfaOpcoOrAdminRoles.length) {
+ return { error: false }
+ }
+ const entrepriseRoles = rolesWithAccess.filter((role) => role.authorized_type === AccessEntityType.ENTREPRISE)
+ if (entrepriseRoles.length) {
+ const entreprises = await Entreprise.find({ _id: { $in: entrepriseRoles.map((role) => role.authorized_id) } }).lean()
+ const hasSomeEntrepriseReady = entreprises.some((entreprise) => getLastStatusEvent(entreprise.status)?.status === EntrepriseStatus.VALIDE)
+ if (hasSomeEntrepriseReady) {
+ return { error: false }
+ }
+ }
+ return { error: true, reason: "VALIDATION" }
+ }
case null:
case undefined:
throw Boom.badRequest("L'état utilisateur est inconnu")
default:
- assertUnreachable(currentState)
+ assertUnreachable(status)
}
}
diff --git a/server/src/services/organization.service.ts b/server/src/services/organization.service.ts
new file mode 100644
index 0000000000..0dd093757f
--- /dev/null
+++ b/server/src/services/organization.service.ts
@@ -0,0 +1,53 @@
+import Boom from "boom"
+import { IUserRecruteur } from "shared/models"
+import { ICFA } from "shared/models/cfa.model"
+import { IEntreprise } from "shared/models/entreprise.model"
+
+import { Cfa, Entreprise } from "@/common/model"
+
+import { CFA, ENTREPRISE } from "./constant.service"
+
+export const createOrganizationIfNotExist = async (organization: Omit): Promise => {
+ const { address, address_detail, establishment_enseigne, establishment_raison_sociale, establishment_siret, geo_coordinates, idcc, opco, origin, type } = organization
+
+ if (!establishment_siret) {
+ throw Boom.internal("siret is missing")
+ }
+ if (type === ENTREPRISE || type === CFA) {
+ let entreprise = await Entreprise.findOne({ siret: establishment_siret }).lean()
+ if (!entreprise) {
+ const entrepriseFields: Omit = {
+ siret: establishment_siret,
+ address,
+ address_detail,
+ enseigne: establishment_enseigne,
+ raison_sociale: establishment_raison_sociale,
+ origin,
+ opco,
+ idcc,
+ geo_coordinates,
+ status: [],
+ }
+ entreprise = (await Entreprise.create(entrepriseFields)).toObject()
+ }
+ if (type === CFA) {
+ let cfa = await Cfa.findOne({ siret: establishment_siret }).lean()
+ if (!cfa) {
+ const cfaFields: Omit = {
+ siret: establishment_siret,
+ address,
+ address_detail,
+ enseigne: establishment_enseigne,
+ raison_sociale: establishment_raison_sociale,
+ origin,
+ geo_coordinates,
+ }
+ cfa = (await Cfa.create(cfaFields)).toObject()
+ }
+ return cfa
+ }
+ return entreprise
+ } else {
+ throw Boom.internal(`type unsupported: ${type}`)
+ }
+}
diff --git a/server/src/services/queryValidator.service.ts b/server/src/services/queryValidator.service.ts
index 7841f95da8..8586bbebf8 100644
--- a/server/src/services/queryValidator.service.ts
+++ b/server/src/services/queryValidator.service.ts
@@ -1,4 +1,5 @@
import axios from "axios"
+import Boom from "boom"
import { allLbaItemTypeOLD } from "shared/constants/lbaitem"
import { isOriginLocal } from "../common/utils/isOriginLocal"
@@ -7,15 +8,37 @@ import { sentryCaptureException } from "../common/utils/sentryUtils"
import config from "../config"
import { TFormationSearchQuery, TJobSearchQuery } from "./jobOpportunity.service.types"
-import { IRncpTCO } from "./queryValidator.service.types"
+import { CertificationAPIApprentissage } from "./queryValidator.service.types"
-const getRomesFromRncp = async (rncp: string): Promise => {
+const getFirstCertificationFromAPIApprentissage = async (rncp: string): Promise => {
try {
- const response = await axios.post(`${config.tco.baseUrl}/api/v1/rncp`, { rncp })
- const romes = response.data.result.romes.map(({ rome }) => rome).join(",")
- return romes ?? null
- } catch (error) {
- sentryCaptureException(error)
+ const { data } = await axios.get(`${config.apiApprentissage.baseUrl}/certification/v1?identifiant.rncp=${rncp}`, {
+ headers: { Authorization: `Bearer ${config.apiApprentissage.apiKey}` },
+ })
+
+ if (!data.length) return null
+
+ return data[0]
+ } catch (error: any) {
+ sentryCaptureException(error, { responseData: error.response?.data })
+ return null
+ }
+}
+
+const getRomesFromRncp = async (rncp: string): Promise => {
+ let certification = await getFirstCertificationFromAPIApprentissage(rncp)
+ if (!certification) return null
+
+ if (certification.periode_validite.rncp.actif) {
+ return certification.domaines.rome.rncp.map((x) => x.code).join(",")
+ } else {
+ const latestRNCP = certification.continuite.rncp.findLast((rncp) => rncp.actif === true)
+ if (!latestRNCP) {
+ throw Boom.internal(`le code RNCP ${rncp} n'a aucune continuité`)
+ }
+ certification = await getFirstCertificationFromAPIApprentissage(latestRNCP.code)
+ if (!certification) return null
+ return certification.domaines.rome.rncp.map((x) => x.code).join(",")
}
}
diff --git a/server/src/services/queryValidator.service.types.ts b/server/src/services/queryValidator.service.types.ts
index 676d62eea4..4f0c6be3b3 100644
--- a/server/src/services/queryValidator.service.types.ts
+++ b/server/src/services/queryValidator.service.types.ts
@@ -1,112 +1,187 @@
-export interface IRncpTCO {
- result: Result
- messages: Messages
-}
-
-interface Result {
- _id: string
- cfds: string[]
- code_rncp: string
- intitule_diplome: string
- date_fin_validite_enregistrement: string
- active_inactive: string
- etat_fiche_rncp: string
- niveau_europe: string
- code_type_certif: any
- type_certif: any
- ancienne_fiche: string[]
- nouvelle_fiche: any
- demande: number
- certificateurs: Certificateur[]
- nsf_code: string
- nsf_libelle: string
- romes: Rome[]
- blocs_competences: BlocsCompetence[]
- voix_acces: any
- partenaires: Partenaire[]
- type_enregistrement: string
- si_jury_ca: string
- eligible_apprentissage: boolean
- created_at: string
- last_update_at: string
- __v: number
- rncp_outdated: boolean
- releated: Releated[]
-}
-
-interface Certificateur {
- certificateur: string
- siret_certificateur: string
+export interface CertificationAPIApprentissage {
+ identifiant: Identifiant
+ intitule: Intitule
+ base_legale: BaseLegale
+ blocs_competences: BlocsCompetences
+ convention_collectives: ConventionCollectives
+ domaines: Domaines
+ periode_validite: PeriodeValidite
+ type: Type
+ continuite: Continuite
}
-interface Rome {
- rome: string
+interface Identifiant {
+ cfd: string
+ rncp: string
+ rncp_anterieur_2019: boolean
+}
+
+interface Intitule {
+ cfd: IntituleCfd
+ niveau: Niveau
+ rncp: string
+}
+
+interface IntituleCfd {
+ long: string
+ court: string
+}
+
+interface Niveau {
+ cfd: NiveauCfd
+ rncp: Rncp
+}
+
+interface NiveauCfd {
+ europeen: string
+ formation_diplome: string
+ interministeriel: string
libelle: string
+ sigle: string
+}
+
+interface Rncp {
+ europeen: string
}
-interface BlocsCompetence {
- numero_bloc: string
+interface BaseLegale {
+ cfd: BaseLegaleCfd
+}
+
+interface BaseLegaleCfd {
+ creation: string
+ abrogation: string
+}
+
+interface BlocsCompetences {
+ rncp: BlocCompetencesRncp[]
+}
+
+interface BlocCompetencesRncp {
+ code: string
intitule: string
- liste_competences: string
- modalites_evaluation: string
}
-interface Partenaire {
- Nom_Partenaire: string
- Siret_Partenaire: string
- Habilitation_Partenaire: string
+interface ConventionCollectives {
+ rncp: ConventionCollectivesRncp[]
}
-interface Releated {
- cfd: Cfd
- mefs: Mefs
+interface ConventionCollectivesRncp {
+ numero: string
+ intitule: string
}
-interface Cfd {
- cfd: string
- cfd_outdated: boolean
- date_fermeture: number
- date_ouverture: number
- specialite: any
- niveau: string
- intitule_long: string
- intitule_court: string
- diplome: string
- libelle_court: string
- niveau_formation_diplome: string
+interface Domaines {
+ formacodes: Formacodes
+ nsf: Nsf
+ rome: Rome
}
-interface Mefs {
- mefs10: any[]
- mefs8: any[]
- mefs_aproximation: any[]
- mefs11: any[]
+interface Formacodes {
+ rncp: FormacodesRncp[]
}
-interface Messages {
- code_rncp: string
- releated: Releated2[]
+interface FormacodesRncp {
+ code: string
+ intitule: string
}
-interface Releated2 {
- cfd: Cfd2
- mefs: Mefs2
+interface Nsf {
+ cfd: NsfCfd
+ rncp: NsfRncp[]
}
-interface Cfd2 {
- cfd: string
- specialite: string
- niveau: string
- intitule_long: string
- intitule_court: string
- diplome: string
- libelle_court: string
- niveau_formation_diplome: string
-}
-
-interface Mefs2 {
- mefs10: string
- mefs8: string
- mefs_aproximation: string
- mefs11: string
+interface NsfCfd {
+ code: string
+ intitule: string
+}
+
+interface NsfRncp {
+ code: string
+ intitule: string
+}
+
+interface Rome {
+ rncp: RomeRncp[]
+}
+
+interface RomeRncp {
+ code: string
+ intitule: string
+}
+
+interface PeriodeValidite {
+ debut: string
+ fin: string
+ cfd: PeriodeValiditeCfd
+ rncp: PeriodeValiditeRncp
+}
+
+interface PeriodeValiditeCfd {
+ ouverture: string
+ fermeture: string
+ premiere_session: number
+ derniere_session: number
+}
+
+interface PeriodeValiditeRncp {
+ actif: boolean
+ activation: string
+ debut_parcours: string
+ fin_enregistrement: string
+}
+
+interface Type {
+ nature: Nature
+ gestionnaire_diplome: string
+ enregistrement_rncp: string
+ voie_acces: VoieAcces
+ certificateurs_rncp: CertificateursRncp[]
+}
+
+interface Nature {
+ cfd: NatureCfd
+}
+
+interface NatureCfd {
+ code: string
+ libelle: string
+}
+
+interface VoieAcces {
+ rncp: VoieAccesRncp
+}
+
+interface VoieAccesRncp {
+ apprentissage: boolean
+ experience: boolean
+ candidature_individuelle: boolean
+ contrat_professionnalisation: boolean
+ formation_continue: boolean
+ formation_statut_eleve: boolean
+}
+
+interface CertificateursRncp {
+ siret: string
+ nom: string
+}
+
+interface Continuite {
+ cfd: ContinuiteCfd[]
+ rncp: ContinuiteRncp[]
+}
+
+interface ContinuiteCfd {
+ ouverture: string
+ fermeture: string
+ code: string
+ courant: boolean
+}
+
+interface ContinuiteRncp {
+ activation: string
+ fin_enregistrement: string
+ code: string
+ courant: boolean
+ actif: boolean
}
diff --git a/server/src/services/roleManagement.service.ts b/server/src/services/roleManagement.service.ts
new file mode 100644
index 0000000000..cdd9e9ec8f
--- /dev/null
+++ b/server/src/services/roleManagement.service.ts
@@ -0,0 +1,165 @@
+import Boom from "boom"
+import type { ObjectId } from "mongodb"
+import { ADMIN, CFA, ENTREPRISE, ETAT_UTILISATEUR, OPCO, OPCOS } from "shared/constants/recruteur"
+import { ComputedUserAccess, IUserRecruteurPublic } from "shared/models"
+import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model"
+import { parseEnum, parseEnumOrError } from "shared/utils"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
+
+import { Cfa, Entreprise, RoleManagement, User2 } from "@/common/model"
+
+import { getFormulaireFromUserIdOrError } from "./formulaire.service"
+
+export const modifyPermissionToUser = async (
+ props: Pick,
+ eventProps: Pick
+): Promise => {
+ const event: IRoleManagementEvent = {
+ ...eventProps,
+ date: new Date(),
+ }
+ const { authorized_id, authorized_type, user_id } = props
+ const role = await RoleManagement.findOne({ authorized_id, authorized_type, user_id }).lean()
+ if (role) {
+ const lastEvent = getLastStatusEvent(role.status)
+ if (lastEvent?.status === eventProps.status) {
+ return role
+ }
+ const newRole = await RoleManagement.findOneAndUpdate({ _id: role._id }, { $push: { status: event } }, { new: true }).lean()
+ if (!newRole) {
+ throw Boom.internal("inattendu")
+ }
+ return newRole
+ } else {
+ const newRole: Omit = {
+ ...props,
+ status: [event],
+ }
+ const role = (await RoleManagement.create(newRole)).toObject()
+ return role
+ }
+}
+
+export const getGrantedRoles = async (userId: string) => {
+ const roles = await RoleManagement.find({ user_id: userId }).lean()
+ return roles.filter((role) => getLastStatusEvent(role.status)?.status === AccessStatus.GRANTED)
+}
+
+// TODO à supprimer lorsque les utilisateurs pourront avoir plusieurs types
+export const getMainRoleManagement = async (userId: string | ObjectId, includeUserAwaitingValidation: boolean = false): Promise => {
+ const validStatus = [AccessStatus.GRANTED]
+ if (includeUserAwaitingValidation) {
+ validStatus.push(AccessStatus.AWAITING_VALIDATION)
+ }
+ const allRoles = await RoleManagement.find({ user_id: userId }).lean()
+ const roles = allRoles.filter((role) => {
+ const status = getLastStatusEvent(role.status)?.status
+ return status ? validStatus.includes(status) : false
+ })
+ const adminRole = roles.find((role) => role.authorized_type === AccessEntityType.ADMIN)
+ if (adminRole) return adminRole
+ const opcoRole = roles.find((role) => role.authorized_type === AccessEntityType.OPCO)
+ if (opcoRole) return opcoRole
+ const cfaRole = roles.find((role) => role.authorized_type === AccessEntityType.CFA)
+ if (cfaRole) return cfaRole
+ const entrepriseRole = roles.find((role) => role.authorized_type === AccessEntityType.ENTREPRISE)
+ if (entrepriseRole) return entrepriseRole
+ return null
+}
+
+export const roleToUserType = (role: IRoleManagement) => {
+ switch (role.authorized_type) {
+ case AccessEntityType.ADMIN:
+ return ADMIN
+ case AccessEntityType.CFA:
+ return CFA
+ case AccessEntityType.ENTREPRISE:
+ return ENTREPRISE
+ case AccessEntityType.OPCO:
+ return OPCO
+ default:
+ return null
+ }
+}
+
+const roleToStatus = (role: IRoleManagement) => {
+ const lastStatus = getLastStatusEvent(role.status)?.status
+ switch (lastStatus) {
+ case AccessStatus.GRANTED:
+ return ETAT_UTILISATEUR.VALIDE
+ case AccessStatus.DENIED:
+ return ETAT_UTILISATEUR.DESACTIVE
+ case AccessStatus.AWAITING_VALIDATION:
+ return ETAT_UTILISATEUR.ATTENTE
+ default:
+ return null
+ }
+}
+
+export const getPublicUserRecruteurPropsOrError = async (
+ userId: string | ObjectId,
+ includeUserAwaitingValidation: boolean = false
+): Promise> => {
+ const mainRole = await getMainRoleManagement(userId, includeUserAwaitingValidation)
+ if (!mainRole) {
+ throw Boom.internal(`inattendu : aucun role trouvé pour user id=${userId}`)
+ }
+ const type = roleToUserType(mainRole)
+ if (!type) {
+ throw Boom.internal(`inattendu : aucun type trouvé pour user id=${userId}`)
+ }
+ const status_current = roleToStatus(mainRole)
+ if (!status_current) {
+ throw Boom.internal(`inattendu : aucun status trouvé pour user id=${userId}`)
+ }
+ const commonFields = {
+ type,
+ status_current,
+ } as const
+ if (type === CFA) {
+ const cfa = await Cfa.findOne({ _id: mainRole.authorized_id }).lean()
+ if (!cfa) {
+ throw Boom.internal(`inattendu : cfa non trouvé pour user id=${userId}`)
+ }
+ const { siret } = cfa
+ return { ...commonFields, establishment_siret: siret }
+ }
+ if (type === ENTREPRISE) {
+ const entreprise = await Entreprise.findOne({ _id: mainRole.authorized_id }).lean()
+ if (!entreprise) {
+ throw Boom.internal(`inattendu : entreprise non trouvée pour user id=${userId}`)
+ }
+ const { siret } = entreprise
+ const user = await User2.findOne({ _id: userId }).lean()
+ if (!user) {
+ throw Boom.internal(`inattendu : user non trouvé`, { userId })
+ }
+ const recruiter = await getFormulaireFromUserIdOrError(user._id.toString())
+ return { ...commonFields, establishment_siret: siret, establishment_id: recruiter.establishment_id }
+ }
+ if (type === OPCO) {
+ return { ...commonFields, scope: parseEnumOrError(OPCOS, mainRole.authorized_id) }
+ }
+ return commonFields
+}
+
+export const getComputedUserAccess = (userId: string, grantedRoles: IRoleManagement[]) => {
+ // TODO
+ // const indirectUserRoles = await RoleManagement.find({ })
+ const userAccess: ComputedUserAccess = {
+ admin: grantedRoles.some((role) => role.authorized_type === AccessEntityType.ADMIN),
+ users: [userId],
+ cfas: grantedRoles.flatMap((role) => (role.authorized_type === AccessEntityType.CFA ? [role.authorized_id] : [])),
+ entreprises: grantedRoles.flatMap((role) => (role.authorized_type === AccessEntityType.ENTREPRISE ? [role.authorized_id] : [])),
+ opcos: grantedRoles.flatMap((role) => {
+ if (role.authorized_type === AccessEntityType.OPCO) {
+ const opco = parseEnum(OPCOS, role.authorized_id)
+ if (opco) {
+ return [opco]
+ }
+ }
+ return []
+ }),
+ }
+ return userAccess
+}
diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts
index f544fd08ff..11922b202b 100644
--- a/server/src/services/user.service.ts
+++ b/server/src/services/user.service.ts
@@ -1,9 +1,15 @@
+import Boom from "boom"
import type { FilterQuery } from "mongoose"
-import { IUser, IUserRecruteur } from "shared"
-import { ETAT_UTILISATEUR } from "shared/constants/recruteur"
+import { IUser } from "shared"
+import { ETAT_UTILISATEUR, OPCOS } from "shared/constants/recruteur"
import { IUserForOpco } from "shared/routes/user.routes"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
-import { Recruiter, User, UserRecruteur } from "../common/model/index"
+import { ObjectId } from "@/common/mongodb"
+
+import { Recruiter, User, User2 } from "../common/model/index"
+
+import { getUserRecruteursForManagement } from "./userRecruteur.service"
/**
* @description Returns user from its email.
@@ -63,62 +69,51 @@ const find = (conditions: FilterQuery) => User.find(conditions)
*/
const findOne = (conditions: FilterQuery) => User.findOne(conditions)
-const getUserAndRecruitersDataForOpcoUser = async (
- opco: string
+export const getUserAndRecruitersDataForOpcoUser = async (
+ opco: OPCOS
): Promise<{
awaiting: IUserForOpco[]
active: IUserForOpco[]
disable: IUserForOpco[]
}> => {
- const [users, recruiters] = await Promise.all([
- UserRecruteur.find({
- $expr: { $ne: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.ERROR] },
- opco,
- })
- .select({
- _id: 1,
- first_name: 1,
- last_name: 1,
- establishment_id: 1,
- establishment_raison_sociale: 1,
- establishment_siret: 1,
- createdAt: 1,
- email: 1,
- phone: 1,
- status: 1,
- type: 1,
- })
- .lean(),
- Recruiter.find({ opco }).select({ establishment_id: 1, origin: 1, jobs: 1, _id: 0 }).lean(),
- ])
-
- const recruiterPerEtablissement = new Map()
- for (const recruiter of recruiters) {
- recruiterPerEtablissement.set(recruiter.establishment_id, recruiter)
- }
-
- const results = users.reduce(
- (acc, user) => {
- const status = user.status?.at(-1)?.status ?? null
- if (status === null) {
- return acc
+ const userRecruteurs = await getUserRecruteursForManagement({ opco })
+ const filteredUserRecruteurs = [...userRecruteurs.active, ...userRecruteurs.awaiting, ...userRecruteurs.disabled]
+ const userIds = [...new Set(filteredUserRecruteurs.map(({ _id }) => _id.toString()))]
+ const recruiters = await Recruiter.find({ "jobs.managed_by": { $in: userIds } })
+ .select({ establishment_id: 1, origin: 1, jobs: 1, _id: 0 })
+ .lean()
+
+ const recruiterMap = new Map()
+ recruiters.forEach((recruiter) => {
+ recruiter.jobs.forEach((job) => {
+ if (!job.managed_by) {
+ throw Boom.internal(`inattendu: managed_by vide pour le job avec id=${job._id}`)
}
- const form = recruiterPerEtablissement.get(user.establishment_id)
+ recruiterMap.set(job.managed_by.toString(), recruiter)
+ })
+ })
- const { _id, first_name, last_name, establishment_id, establishment_raison_sociale, establishment_siret, createdAt, email, phone, type } = user
+ const results = filteredUserRecruteurs.reduce(
+ (acc, userRecruteur) => {
+ const status = getLastStatusEvent(userRecruteur.status)?.status
+ if (!status) return acc
+ const recruiter = recruiterMap.get(userRecruteur._id.toString())
+ const { establishment_id } = recruiter ?? {}
+ const { _id, first_name, last_name, establishment_raison_sociale, establishment_siret, createdAt, email, phone, type, organizationId } = userRecruteur
const userForOpco: IUserForOpco = {
_id,
first_name,
last_name,
- establishment_id,
establishment_raison_sociale,
establishment_siret,
+ establishment_id,
createdAt,
email,
phone,
type,
- jobs_count: form?.jobs?.length ?? 0,
- origin: form?.origin ?? "",
+ jobs_count: recruiter?.jobs?.length ?? 0,
+ origin: recruiter?.origin ?? "",
+ organizationId,
}
if (status === ETAT_UTILISATEUR.ATTENTE) {
acc.awaiting.push(userForOpco)
@@ -140,14 +135,10 @@ const getUserAndRecruitersDataForOpcoUser = async (
return results
}
-const getValidatorIdentityFromStatus = async (status: IUserRecruteur["status"]) => {
- return await Promise.all(
- status.map(async (state) => {
- if (state.user === "SERVEUR") return state
- const user = await UserRecruteur.findById(state.user).select({ first_name: 1, last_name: 1, _id: 0 }).lean()
- return { ...state, user: `${user?.first_name} ${user?.last_name}` }
- })
- )
+export const getUserNamesFromIds = async (ids: string[]) => {
+ const deduplicatedIds = [...new Set(ids)].filter((id) => ObjectId.isValid(id))
+ const users = await User2.find({ _id: { $in: deduplicatedIds } }).lean()
+ return users
}
-export { createUser, find, findOne, getUserAndRecruitersDataForOpcoUser, getUserById, getUserByMail, getValidatorIdentityFromStatus, update }
+export { createUser, find, findOne, getUserById, getUserByMail, update }
diff --git a/server/src/services/user2.service.ts b/server/src/services/user2.service.ts
new file mode 100644
index 0000000000..42499efc4e
--- /dev/null
+++ b/server/src/services/user2.service.ts
@@ -0,0 +1,76 @@
+import Boom from "boom"
+import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur"
+import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model"
+
+import { User2 } from "@/common/model"
+import { ObjectId } from "@/common/mongodb"
+
+import { isUserEmailChecked } from "./userRecruteur.service"
+
+export const createUser2IfNotExist = async (
+ userProps: Omit,
+ is_email_checked: boolean,
+ grantedBy: string
+): Promise => {
+ const { first_name, last_name, last_action_date, origin, phone } = userProps
+ const formatedEmail = userProps.email.toLocaleLowerCase()
+
+ let user = await User2.findOne({ email: formatedEmail }).lean()
+ if (!user) {
+ const id = new ObjectId()
+ grantedBy = grantedBy || id.toString()
+ const status: IUserStatusEvent[] = []
+ if (is_email_checked) {
+ status.push({
+ date: new Date(),
+ reason: "validation de l'email à la création",
+ status: UserEventType.VALIDATION_EMAIL,
+ validation_type: VALIDATION_UTILISATEUR.MANUAL,
+ granted_by: grantedBy,
+ })
+ }
+ status.push({
+ date: new Date(),
+ reason: "creation de l'utilisateur",
+ status: UserEventType.ACTIF,
+ validation_type: VALIDATION_UTILISATEUR.MANUAL,
+ granted_by: grantedBy,
+ })
+ const userFields: Omit = {
+ _id: id,
+ email: formatedEmail,
+ first_name,
+ last_name,
+ phone: phone ?? "",
+ last_action_date: last_action_date ?? new Date(),
+ origin,
+ status,
+ }
+ user = (await User2.create(userFields)).toObject()
+ }
+ return user
+}
+
+export const validateUser2Email = async (id: string): Promise => {
+ const userOpt = await User2.findOne({ _id: id }).lean()
+ if (!userOpt) {
+ throw Boom.internal(`utilisateur avec id=${id} non trouvé`)
+ }
+ if (isUserEmailChecked(userOpt)) {
+ return userOpt
+ }
+ const event: IUserStatusEvent = {
+ date: new Date(),
+ status: UserEventType.VALIDATION_EMAIL,
+ validation_type: VALIDATION_UTILISATEUR.MANUAL,
+ granted_by: id,
+ reason: "validation de l'email par l'utilisateur",
+ }
+ const newUser = await User2.findOneAndUpdate({ _id: id }, { $push: { status: event } }, { new: true }).lean()
+ if (!newUser) {
+ throw Boom.internal(`utilisateur avec id=${id} non trouvé`)
+ }
+ return newUser
+}
+
+export const getUser2ByEmail = async (email: string): Promise => User2.findOne({ email: email.toLocaleLowerCase() }).lean()
diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts
index d3d558ef47..ca7cf646ed 100644
--- a/server/src/services/userRecruteur.service.ts
+++ b/server/src/services/userRecruteur.service.ts
@@ -1,19 +1,28 @@
import { randomUUID } from "crypto"
import Boom from "boom"
-import type { FilterQuery, ModelUpdateOptions, UpdateQuery } from "mongoose"
-import { IUserRecruteur, IUserRecruteurWritable, IUserStatusValidation, UserRecruteurForAdminProjection } from "shared"
-import { CFA, ENTREPRISE, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur"
-import { entriesToTypedRecord, typedKeys } from "shared/utils/objectUtils"
+import { IRecruiter, IUserRecruteur, IUserRecruteurForAdmin, IUserStatusValidation, assertUnreachable, parseEnumOrError, removeUndefinedFields } from "shared"
+import { BusinessErrorCodes } from "shared/constants/errorCodes"
+import { ADMIN, CFA, ENTREPRISE, ETAT_UTILISATEUR, OPCO, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur"
+import { ICFA } from "shared/models/cfa.model"
+import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent } from "shared/models/entreprise.model"
+import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model"
+import { IUser2, UserEventType } from "shared/models/user2.model"
+import { getLastStatusEvent } from "shared/utils/getLastStatusEvent"
+import { ObjectId, ObjectIdType } from "@/common/mongodb"
import { getStaticFilePath } from "@/common/utils/getStaticFilePath"
+import { user2ToUserForToken } from "@/security/accessTokenService"
-import { UserRecruteur } from "../common/model/index"
+import { Cfa, Entreprise, Recruiter, RoleManagement, User2 } from "../common/model/index"
import config from "../config"
import { createAuthMagicLink } from "./appLinks.service"
-import { ADMIN } from "./constant.service"
+import { getFormulaireFromUserIdOrError } from "./formulaire.service"
import mailer, { sanitizeForEmail } from "./mailer.service"
+import { createOrganizationIfNotExist } from "./organization.service"
+import { modifyPermissionToUser } from "./roleManagement.service"
+import { createUser2IfNotExist } from "./user2.service"
/**
* @description generate an API key
@@ -21,100 +30,275 @@ import mailer, { sanitizeForEmail } from "./mailer.service"
*/
export const createApiKey = (): string => `mna-${randomUUID()}`
-/**
- * @query get all user using a given query filter
- * @param {Filter} query
- * @param {Object} options
- * @param {Object} pagination
- * @param {Number} pagination.page
- * @param {Number} pagination.limit
- * @returns {Promise}
- */
-export const getUsers = async (query: FilterQuery, options, { page, limit }) => {
- const response = await UserRecruteur.paginate({ query, ...options, page, limit, lean: true })
+const entrepriseStatusEventToUserRecruteurStatusEvent = (entrepriseStatusEvent: IEntrepriseStatusEvent, forcedStatus: ETAT_UTILISATEUR): IUserStatusValidation => {
+ const { date, reason, validation_type, granted_by } = entrepriseStatusEvent
return {
- pagination: {
- page: response?.page,
- result_per_page: limit,
- number_of_page: response?.totalPages,
- total: response?.totalDocs,
- },
- data: response?.docs,
+ date,
+ user: granted_by ?? "",
+ validation_type,
+ reason,
+ status: forcedStatus,
}
}
-/**
- * @description get a single user using a given query filter
- * @param {Filter} query
- * @returns {Promise}
- */
-export const getUser = async (query: FilterQuery): Promise => UserRecruteur.findOne(query).lean()
+const getOrganismeFromRole = async (role: IRoleManagement): Promise => {
+ switch (role.authorized_type) {
+ case AccessEntityType.ENTREPRISE: {
+ const entreprise = await Entreprise.findOne({ _id: role.authorized_id }).lean()
+ if (!entreprise) {
+ throw Boom.internal(`could not find entreprise for role ${role._id}`)
+ }
+ return entreprise
+ }
+ case AccessEntityType.CFA: {
+ const cfa = await Cfa.findOne({ _id: role.authorized_id }).lean()
+ if (!cfa) {
+ throw Boom.internal(`could not find cfa for role ${role._id}`)
+ }
+ return cfa
+ }
+ default:
+ return null
+ }
+}
-/**
- * @description création d'un nouveau user recruteur. Le champ status peut être passé ou, s'il n'est pas passé, être sauvé ultérieurement
- */
-export const createUser = async (
- userRecruteurProps: Omit & Partial>
-): Promise => {
- let scope = userRecruteurProps.scope ?? undefined
-
- const formatedEmail = userRecruteurProps.email.toLocaleLowerCase()
-
- if (!scope) {
- if (userRecruteurProps.type === "CFA") {
- // generate user scope
- const [key] = randomUUID().split("-")
- scope = `cfa-${key}`
- } else {
- let key
- if (userRecruteurProps?.establishment_raison_sociale) {
- key = userRecruteurProps.establishment_raison_sociale.toLowerCase().replace(/ /g, "-")
- } else {
- key = randomUUID().split("-")[0]
+const roleStatusToUserRecruteurStatus = (roleStatus: AccessStatus): ETAT_UTILISATEUR => {
+ switch (roleStatus) {
+ case AccessStatus.GRANTED:
+ return ETAT_UTILISATEUR.VALIDE
+ case AccessStatus.DENIED:
+ return ETAT_UTILISATEUR.DESACTIVE
+ case AccessStatus.AWAITING_VALIDATION:
+ return ETAT_UTILISATEUR.ATTENTE
+ default:
+ assertUnreachable(roleStatus)
+ }
+}
+
+export const getUserRecruteurById = (id: string | ObjectIdType) => getUserRecruteurByUser2Query({ _id: typeof id === "string" ? new ObjectId(id) : id })
+export const getUserRecruteurByEmail = (email: string) => getUserRecruteurByUser2Query({ email })
+
+export const userAndRoleAndOrganizationToUserRecruteur = (
+ user: IUser2,
+ role: IRoleManagement,
+ organisme: ICFA | IEntreprise | null,
+ formulaire: IRecruiter | null
+): IUserRecruteur => {
+ const { email, first_name, last_name, phone, last_action_date, _id } = user
+ const organismeType = organisme ? ("status" in organisme ? ENTREPRISE : CFA) : null
+ const oldStatus: IUserStatusValidation[] = [
+ ...role.status.map(({ date, reason, status, validation_type, granted_by }) => {
+ const userRecruteurStatus = roleStatusToUserRecruteurStatus(status)
+ return {
+ date,
+ reason,
+ status: userRecruteurStatus,
+ validation_type,
+ user: granted_by ?? "",
}
- scope = `etp-${key}`
+ }),
+ ]
+ if (organisme && "status" in organisme) {
+ const lastStatusEvent = getLastStatusEvent(organisme.status)
+ if (lastStatusEvent?.status === EntrepriseStatus.ERROR) {
+ oldStatus.push(entrepriseStatusEventToUserRecruteurStatusEvent(lastStatusEvent, ETAT_UTILISATEUR.ERROR))
}
}
- const createdUser = await UserRecruteur.create({
- status: [],
- ...userRecruteurProps,
- scope,
- email: formatedEmail,
- })
- return createdUser.toObject()
+
+ const roleType = role.authorized_type === AccessEntityType.OPCO ? OPCO : role.authorized_type === AccessEntityType.ADMIN ? ADMIN : null
+ const type = roleType ?? organismeType ?? null
+ if (!type) throw Boom.internal("unexpected: no type found")
+ const { siret, address, address_detail, geo_coordinates, origin, raison_sociale, enseigne } = organisme ?? {}
+ let entrepriseFields = {}
+ if (organisme && "opco" in organisme) {
+ const { idcc, opco } = organisme
+ entrepriseFields = { idcc, opco }
+ }
+ if (formulaire) {
+ const { establishment_id } = formulaire
+ Object.assign(entrepriseFields, { establishment_id })
+ }
+ const userRecruteur: IUserRecruteur = {
+ ...entrepriseFields,
+ establishment_siret: siret,
+ establishment_enseigne: enseigne,
+ establishment_raison_sociale: raison_sociale,
+ address,
+ address_detail,
+ geo_coordinates,
+ origin,
+ is_qualiopi: type === CFA,
+ createdAt: role?.createdAt ?? user.createdAt,
+ updatedAt: role?.updatedAt ?? user.updatedAt,
+ is_email_checked: isUserEmailChecked(user),
+ type,
+ _id,
+ email,
+ first_name,
+ last_name,
+ phone,
+ last_connection: last_action_date,
+ status: oldStatus,
+ }
+ return userRecruteur
+}
+
+const getUserRecruteurByUser2Query = async (user2query: Partial): Promise => {
+ const user = await User2.findOne(user2query).lean()
+ if (!user) return null
+ const role = await RoleManagement.findOne({ user_id: user._id.toString() }).lean()
+ if (!role) return null
+ const organisme = await getOrganismeFromRole(role)
+ if (!organisme) return null
+ const formulaire = role.authorized_type === AccessEntityType.ENTREPRISE ? await getFormulaireFromUserIdOrError(user._id.toString()) : null
+ return userAndRoleAndOrganizationToUserRecruteur(user, role, organisme, formulaire)
}
/**
- * @description update user
- * @param {Filter} query
- * @param {UpdateQuery} update
- * @param {ModelUpdateOptions} options
- * @returns {Promise}
+ * Crée l'utilisateur si il n'existe pas
+ * Crée l'organisation si elle n'existe pas
+ * Si statusEvent est passé, ajoute les droits de l'utilisateur sur l'organisation.
+ * Sinon, c'est de la responsabilité de l'appelant d'ajouter le status des droits ultérieurement.
*/
-export const updateUser = async (
- query: FilterQuery,
- update: Partial,
- options: ModelUpdateOptions = { new: true }
-): Promise => {
- const userRecruterOpt = await UserRecruteur.findOneAndUpdate(query, update, options).lean()
- if (!userRecruterOpt) {
- throw Boom.internal(`could not update one user from query=${JSON.stringify(query)}`)
+export const createOrganizationUser = async (
+ userRecruteurProps: Omit,
+ grantedBy?: string,
+ statusEvent?: Pick
+): Promise => {
+ const { type, origin, first_name, last_name, last_connection, email, is_email_checked, phone } = userRecruteurProps
+ if (type === ENTREPRISE || type === CFA) {
+ const user = await createUser2IfNotExist(
+ {
+ email,
+ first_name,
+ last_name,
+ phone: phone ?? "",
+ last_action_date: last_connection,
+ },
+ is_email_checked,
+ grantedBy ?? ""
+ )
+ const organization = await createOrganizationIfNotExist(userRecruteurProps)
+ if (statusEvent) {
+ await modifyPermissionToUser(
+ {
+ user_id: user._id,
+ authorized_id: organization._id.toString(),
+ authorized_type: type === ENTREPRISE ? AccessEntityType.ENTREPRISE : AccessEntityType.CFA,
+ origin: origin ?? "createUser",
+ },
+ statusEvent
+ )
+ }
+ return { organization, user, type }
+ } else {
+ throw Boom.internal(`unsupported type ${type}`)
}
- return userRecruterOpt
+}
+
+export const createOpcoUser = async (userProps: Pick, opco: OPCOS, grantedBy: string) => {
+ const user = await createUser2IfNotExist(
+ {
+ ...userProps,
+ last_action_date: new Date(),
+ },
+ false,
+ grantedBy
+ )
+ await modifyPermissionToUser(
+ {
+ user_id: user._id,
+ authorized_id: opco,
+ authorized_type: AccessEntityType.OPCO,
+ origin: "",
+ },
+ {
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ status: AccessStatus.GRANTED,
+ reason: "",
+ }
+ )
+ return user
+}
+
+export const createAdminUser = async (
+ userProps: Pick,
+ { grantedBy, origin = "", reason = "" }: { reason?: string; origin?: string; grantedBy: string }
+) => {
+ const user = await createUser2IfNotExist(
+ {
+ ...userProps,
+ last_action_date: new Date(),
+ },
+ false,
+ grantedBy
+ )
+ await modifyPermissionToUser(
+ {
+ user_id: user._id,
+ authorized_id: "",
+ authorized_type: AccessEntityType.ADMIN,
+ origin,
+ },
+ {
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ status: AccessStatus.GRANTED,
+ reason,
+ }
+ )
+ return user
}
/**
- * @description delete user from collection
- * @param {IUserRecruteur["_id"]} id
- * @returns {Promise}
+ * @description création d'un nouveau user recruteur. Le champ status peut être passé ou, s'il n'est pas passé, être sauvé ultérieurement
*/
-export const removeUser = async (id: IUserRecruteur["_id"] | string) => {
- const user = await UserRecruteur.findById(id)
- if (!user) {
- throw new Error(`Unable to find user ${id}`)
+export const createUser = async (
+ userProps: Omit,
+ grantedBy: string,
+ statusEvent?: Pick
+): Promise => {
+ const { first_name, last_name, email, phone, type, opco } = userProps
+ const userFields = {
+ first_name,
+ last_name,
+ email,
+ phone: phone ?? "",
+ }
+
+ if (type === ENTREPRISE || type === CFA) {
+ const { user } = await createOrganizationUser(userProps, grantedBy, statusEvent)
+ return user
+ } else if (type === ADMIN) {
+ const user = await createAdminUser(userFields, { grantedBy })
+ return user
+ } else if (type === OPCO) {
+ const user = await createOpcoUser(userFields, parseEnumOrError(OPCOS, opco ?? null), grantedBy)
+ return user
+ } else {
+ assertUnreachable(type)
+ }
+}
+
+export const updateUser2Fields = async (userId: ObjectIdType, fields: Partial): Promise => {
+ const { email, first_name, last_name, phone } = fields
+ const newEmail = email?.toLocaleLowerCase()
+
+ if (newEmail) {
+ const exist = await User2.findOne({ email: newEmail, _id: { $ne: userId } }).lean()
+ if (exist) {
+ return { error: BusinessErrorCodes.EMAIL_ALREADY_EXISTS }
+ }
+ }
+ const newUser = await User2.findOneAndUpdate({ _id: userId }, removeUndefinedFields({ ...fields, email: newEmail }), { new: true }).lean()
+ if (!newUser) {
+ throw Boom.badRequest("user not found")
}
+ await Recruiter.updateMany({ "jobs.managed_by": userId.toString() }, { $set: removeUndefinedFields({ first_name, last_name, phone, email: newEmail }) })
+ return newUser
+}
- return await UserRecruteur.deleteOne({ _id: id })
+export const removeUser = async (id: IUser2["_id"] | string) => {
+ await RoleManagement.deleteMany({ user_id: id })
}
/**
@@ -122,21 +306,9 @@ export const removeUser = async (id: IUserRecruteur["_id"] | string) => {
* @param {IUserRecruteur["email"]} email
* @returns {Promise}
*/
-export const updateLastConnectionDate = (email: IUserRecruteur["email"]) =>
- UserRecruteur.findOneAndUpdate({ email: email.toLowerCase() }, { last_connection: new Date() }, { new: true }).lean()
-
-/**
- * @description update user validation status
- * @param {IUserRecruteur["_id"]} userId
- * @param {UpdateQuery}
- */
-export const updateUserValidationHistory = async (
- userId: IUserRecruteur["_id"],
- state: UpdateQuery,
- options: ModelUpdateOptions = { new: true }
-): Promise => await UserRecruteur.findByIdAndUpdate({ _id: userId }, { $push: { status: state } }, options).lean()
+export const updateLastConnectionDate = async (email: IUserRecruteur["email"]): Promise => {
+ await User2.findOneAndUpdate({ email: email.toLowerCase() }, { last_action_date: new Date() }, { new: true }).lean()
+}
/**
* @description get last user validation state from status array, by creation date
@@ -152,58 +324,77 @@ export const getUserStatus = (stateArray: IUserRecruteur["status"]): IUserStatus
return lastValidationEvent.status
}
-export const setUserInError = async (userId: IUserRecruteur["_id"], reason: string) => {
- const response = await updateUserValidationHistory(userId, {
- validation_type: VALIDATION_UTILISATEUR.AUTO,
- user: "SERVEUR",
- status: ETAT_UTILISATEUR.ERROR,
- reason,
- })
- if (!response) {
- throw new Error(`could not find user history for user with id=${userId}`)
- }
- return response
+export const setEntrepriseValid = async (entrepriseId: IEntreprise["_id"]) => {
+ return setEntrepriseStatus(entrepriseId, "", EntrepriseStatus.VALIDE)
}
-export const autoValidateUser = async (userId: IUserRecruteur["_id"]) => {
- const response = await updateUserValidationHistory(userId, {
- validation_type: VALIDATION_UTILISATEUR.AUTO,
- user: "SERVEUR",
- status: ETAT_UTILISATEUR.VALIDE,
- })
- if (!response) {
- throw new Error(`could not find user history for user with id=${userId}`)
- }
- return response
+export const setEntrepriseInError = async (entrepriseId: IEntreprise["_id"], reason: string) => {
+ return setEntrepriseStatus(entrepriseId, reason, EntrepriseStatus.ERROR)
}
-export const setUserHasToBeManuallyValidated = async (userId: IUserRecruteur["_id"]) => {
- const response = await updateUserValidationHistory(userId, {
+export const setEntrepriseStatus = async (entrepriseId: IEntreprise["_id"], reason: string, status: EntrepriseStatus) => {
+ const entreprise = await Entreprise.findOne({ _id: entrepriseId })
+ if (!entreprise) {
+ throw Boom.internal(`could not find entreprise with id=${entrepriseId}`)
+ }
+ const lastStatus = getLastStatusEvent(entreprise.status)?.status
+ if (lastStatus === status && status === EntrepriseStatus.VALIDE) return
+ const event: IEntrepriseStatusEvent = {
+ date: new Date(),
+ reason,
+ status,
validation_type: VALIDATION_UTILISATEUR.AUTO,
- user: "SERVEUR",
- status: ETAT_UTILISATEUR.ATTENTE,
- })
- if (!response) {
- throw new Error(`could not find user history for user with id=${userId}`)
}
- return response
+ await Entreprise.updateOne(
+ { _id: entrepriseId },
+ {
+ $push: {
+ status: event,
+ },
+ }
+ )
}
-export const deactivateUser = async (userId: IUserRecruteur["_id"], reason?: string) => {
- const response = await updateUserValidationHistory(userId, {
- validation_type: VALIDATION_UTILISATEUR.AUTO,
- user: "SERVEUR",
- status: ETAT_UTILISATEUR.DESACTIVE,
- reason,
- })
- if (!response) {
- throw new Error(`could not find user history for user with id=${userId}`)
- }
- return response
+const setAccessOfUserOnOrganization = async ({ user, organization, type }: UserAndOrganization, status: AccessStatus, origin: string, reason: string) => {
+ await modifyPermissionToUser(
+ {
+ user_id: user._id,
+ authorized_id: organization._id.toString(),
+ authorized_type: type === ENTREPRISE ? AccessEntityType.ENTREPRISE : AccessEntityType.CFA,
+ origin,
+ },
+ {
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ status,
+ reason,
+ }
+ )
}
-export const sendWelcomeEmailToUserRecruteur = async (userRecruteur: IUserRecruteur) => {
- const { email, first_name, last_name, establishment_raison_sociale, type } = userRecruteur
+export const autoValidateUser = async (props: UserAndOrganization, origin: string, reason: string) => {
+ await setAccessOfUserOnOrganization(props, AccessStatus.GRANTED, origin, reason)
+}
+
+export const setUserHasToBeManuallyValidated = async (props: UserAndOrganization, origin: string, reason: string) => {
+ await setAccessOfUserOnOrganization(props, AccessStatus.AWAITING_VALIDATION, origin, reason)
+}
+
+export const deactivateEntreprise = async (entrepriseId: IEntreprise["_id"], reason: string) => {
+ return setEntrepriseStatus(entrepriseId, reason, EntrepriseStatus.DESACTIVE)
+}
+
+export const sendWelcomeEmailToUserRecruteur = async (user: IUser2) => {
+ const { email, first_name, last_name } = user
+ const role = await RoleManagement.findOne({ user_id: user._id, authorized_type: { $in: [AccessEntityType.ENTREPRISE, AccessEntityType.CFA] } }).lean()
+ if (!role) {
+ throw Boom.internal(`inattendu : pas de role pour user id=${user._id}`)
+ }
+ const isCfa = role.authorized_type === AccessEntityType.CFA
+ const organization = await (isCfa ? Cfa : Entreprise).findOne({ _id: role.authorized_id }).lean()
+ if (!organization) {
+ throw Boom.internal(`inattendu : pas d'organization pour user id=${user._id} et role id=${role._id}`)
+ }
+ const { raison_sociale: establishment_raison_sociale } = organization
await mailer.sendEmail({
to: email,
subject: "Bienvenue sur La bonne alternance",
@@ -216,44 +407,115 @@ export const sendWelcomeEmailToUserRecruteur = async (userRecruteur: IUserRecrut
last_name: sanitizeForEmail(last_name),
first_name: sanitizeForEmail(first_name),
email: sanitizeForEmail(email),
- is_delegated: type === CFA,
- url: createAuthMagicLink(userRecruteur),
+ is_delegated: isCfa,
+ url: createAuthMagicLink(user2ToUserForToken(user)),
},
})
}
-const projection = entriesToTypedRecord(typedKeys(UserRecruteurForAdminProjection).map((key) => [key, 1 as const]))
-
-export const getAdminUsers = () => UserRecruteur.find({ type: ADMIN }).lean()
+export const getAdminUsers = async () => {
+ const allRoles = await RoleManagement.find({
+ authorized_type: AccessEntityType.ADMIN,
+ }).lean()
+ const grantedRoles = allRoles.filter((role) => getLastStatusEvent(role.status)?.status === AccessStatus.GRANTED)
+ const userIds = grantedRoles.map((role) => role.user_id.toString())
+ const users = await User2.find({ _id: { $in: userIds } }).lean()
+ return users
+}
-export const getActiveUsers = () =>
- UserRecruteur.find({
- $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.VALIDE] },
- $or: [{ type: CFA }, { type: ENTREPRISE }],
- })
- .select(projection)
+export const getUserRecruteursForManagement = async ({ opco, activeRoleLimit }: { opco?: OPCOS; activeRoleLimit?: number }) => {
+ const nonGrantedRoles = await RoleManagement.find({ $expr: { $ne: [{ $arrayElemAt: ["$status.status", -1] }, AccessStatus.GRANTED] } }).lean()
+ const lastGrantedRoles = await RoleManagement.find({ $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, AccessStatus.GRANTED] } })
+ .sort({ updatedAt: -1 })
+ .limit(activeRoleLimit ?? 1000)
.lean()
+ const roles = [...nonGrantedRoles, ...lastGrantedRoles]
-export const getAwaitingUsers = () =>
- UserRecruteur.find({
- $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.ATTENTE] },
- $or: [{ type: CFA }, { type: ENTREPRISE }],
- })
- .select(projection)
- .lean()
+ const userIds = roles.map((role) => role.user_id.toString())
+ const users = await User2.find({ _id: { $in: userIds } }).lean()
-export const getDisabledUsers = () =>
- UserRecruteur.find({
- $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.DESACTIVE] },
- $or: [{ type: CFA }, { type: ENTREPRISE }],
- })
- .select(projection)
- .lean()
+ const entrepriseIds = roles.flatMap((role) => (role.authorized_type === AccessEntityType.ENTREPRISE ? [role.authorized_id] : []))
+ const entreprises = await Entreprise.find({ _id: { $in: entrepriseIds }, ...(opco ? { opco } : {}) }).lean()
-export const getErrorUsers = () =>
- UserRecruteur.find({
- $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.ERROR] },
- $or: [{ type: CFA }, { type: ENTREPRISE }],
- })
- .select(projection)
- .lean()
+ const cfaIds = opco ? [] : roles.flatMap((role) => (role.authorized_type === AccessEntityType.CFA ? [role.authorized_id] : []))
+ const cfas = cfaIds.length ? await Cfa.find({ _id: { $in: cfaIds } }).lean() : []
+
+ const userRecruteurs = roles
+ .flatMap<{ user: IUser2; role: IRoleManagement } & ({ entreprise: IEntreprise } | { cfa: ICFA })>((role) => {
+ const user = users.find((user) => user._id.toString() === role.user_id.toString())
+ if (!user) return []
+ const { authorized_type } = role
+ if (authorized_type === AccessEntityType.ENTREPRISE) {
+ const entreprise = entreprises.find((entreprise) => entreprise._id.toString() === role.authorized_id)
+ if (!entreprise) return []
+ return [{ user, role, entreprise, type: ENTREPRISE }]
+ } else if (authorized_type === AccessEntityType.CFA) {
+ const cfa = cfas.find((cfa) => cfa._id.toString() === role.authorized_id)
+ if (!cfa) return []
+ return [{ user, role, cfa, type: CFA }]
+ } else {
+ return []
+ }
+ })
+ .map((result) => {
+ const { user, role } = result
+ const organization = "entreprise" in result ? result.entreprise : result.cfa
+ const userRecruteur = userAndRoleAndOrganizationToUserRecruteur(user, role, organization, null)
+ const { _id, establishment_raison_sociale, establishment_siret, type, first_name, last_name, email, phone, createdAt, origin, opco, status } = userRecruteur
+ const userRecruteurForAdmin: IUserRecruteurForAdmin = {
+ _id,
+ establishment_raison_sociale,
+ establishment_siret,
+ type,
+ first_name,
+ last_name,
+ email,
+ phone,
+ createdAt,
+ origin,
+ opco,
+ status,
+ organizationId: organization._id,
+ }
+ return userRecruteurForAdmin
+ })
+ return userRecruteurs.reduce(
+ (acc, userRecruteur) => {
+ const lastStatus = getLastStatusEvent(userRecruteur.status)?.status
+ switch (lastStatus) {
+ case ETAT_UTILISATEUR.DESACTIVE: {
+ acc.disabled.push(userRecruteur)
+ return acc
+ }
+ case ETAT_UTILISATEUR.ATTENTE: {
+ acc.awaiting.push(userRecruteur)
+ return acc
+ }
+ case ETAT_UTILISATEUR.ERROR: {
+ acc.error.push(userRecruteur)
+ return acc
+ }
+ case ETAT_UTILISATEUR.VALIDE: {
+ acc.active.push(userRecruteur)
+ return acc
+ }
+ default:
+ return acc
+ }
+ },
+ {
+ awaiting: [] as IUserRecruteurForAdmin[],
+ active: [] as IUserRecruteurForAdmin[],
+ disabled: [] as IUserRecruteurForAdmin[],
+ error: [] as IUserRecruteurForAdmin[],
+ }
+ )
+}
+
+export const getUsersForAdmin = async () => {
+ return getUserRecruteursForManagement({ activeRoleLimit: 40 })
+}
+
+export const isUserEmailChecked = (user: IUser2): boolean => user.status.some((event) => event.status === UserEventType.VALIDATION_EMAIL)
+
+export type UserAndOrganization = { user: IUser2; organization: IEntreprise | ICFA; type: "ENTREPRISE" | "CFA" }
diff --git a/server/static/templates/mail-nouvelle-offre-depot-simplifie.mjml.ejs b/server/static/templates/mail-nouvelle-offre-depot-simplifie.mjml.ejs
index 134536d751..1539119e1d 100644
--- a/server/static/templates/mail-nouvelle-offre-depot-simplifie.mjml.ejs
+++ b/server/static/templates/mail-nouvelle-offre-depot-simplifie.mjml.ejs
@@ -42,7 +42,7 @@
Merci de cliquer sur le lien ci-dessous pour confirmer votre adresse mail, nous nous chargeons de vérifier votre compte :
Confirmer mon adresse mail
- Si le lien ne fonctionne pas, copier le lien suivant dans le navigateur : <%= data.confirmation_url %>
+ Si le lien ne fonctionne pas, copier le lien suivant dans le navigateur : <%= data.confirmation_url %>
@@ -73,7 +73,7 @@
<% if(!data.isUserAwaiting) { %>
Afin de pouvoir diffuser votre offre et accéder à votre espace de gestion, merci de cliquer sur le lien ci-dessous pour valider votre adresse mail:
Confirmer mon adresse mail
- Si le lien ne fonctionne pas, copier le lien suivant dans le navigateur : <%= data.confirmation_url %>
+ Si le lien ne fonctionne pas, copier le lien suivant dans le navigateur : <%= data.confirmation_url %>
<% } %>
L'équipe La bonne alternance,
diff --git a/server/tests/integration/http/jobV1.test.ts b/server/tests/integration/http/jobV1.test.ts
index 91abfd9ccc..70be9fe197 100644
--- a/server/tests/integration/http/jobV1.test.ts
+++ b/server/tests/integration/http/jobV1.test.ts
@@ -167,7 +167,7 @@ describe.skip("jobV1", () => {
)
})
- it("Vérifie que la route offre PE par id répond", async () => {
+ it("Vérifie que la route offre FT par id répond", async () => {
const response = await httpClient().inject({ method: "GET", path: "/api/V1/jobs/job/110MSJT" })
expect(response.statusCode).toBe(200)
diff --git a/server/tests/integration/http/ratelimit.test.ts b/server/tests/integration/http/ratelimit.test.ts
deleted file mode 100644
index 51a1778e17..0000000000
--- a/server/tests/integration/http/ratelimit.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import assert from "assert"
-
-import { describe, it, beforeAll } from "vitest"
-
-import { enableRateLimiter } from "@/http/utils/rateLimiters"
-import { useMongo } from "@tests/utils/mongo.utils"
-import { useServer } from "@tests/utils/server.utils"
-
-describe("ratelimit", () => {
- useMongo()
- const httpClient = useServer()
-
- beforeAll(() => {
- enableRateLimiter()
- })
-
- it("rate-limit, exemple avec /api/version : 6 requêtes consécutives : les 5 premières sont acceptées, mais pas la 6ème", async () => {
- const response1 = await httpClient().inject({ method: "GET", path: "/api/version", remoteAddress: "101.188.67.134" })
- const response2 = await httpClient().inject({ method: "GET", path: "/api/version", remoteAddress: "101.188.67.134" })
- const response3 = await httpClient().inject({ method: "GET", path: "/api/version", remoteAddress: "101.188.67.134" })
- const response4 = await httpClient().inject({ method: "GET", path: "/api/version", remoteAddress: "101.188.67.134" })
-
- assert.strictEqual(response1.statusCode, 200)
- assert.strictEqual(response2.statusCode, 200)
- assert.strictEqual(response3.statusCode, 200)
- assert.strictEqual(response4.statusCode, 429)
- })
-})
diff --git a/server/tests/unit/security/accessTokenService.test.ts b/server/tests/unit/security/accessTokenService.test.ts
index c3a9a3a7f9..49b050c0d5 100644
--- a/server/tests/unit/security/accessTokenService.test.ts
+++ b/server/tests/unit/security/accessTokenService.test.ts
@@ -1,25 +1,59 @@
-import { IUserRecruteur, zRoutes } from "shared"
-import { ENTREPRISE, ETAT_UTILISATEUR } from "shared/constants/recruteur"
+import { zRoutes } from "shared"
import { z } from "shared/helpers/zodWithOpenApi"
+import { EntrepriseStatus } from "shared/models/entreprise.model"
+import { AccessStatus } from "shared/models/roleManagement.model"
import { describe, expect, it } from "vitest"
-import { SchemaWithSecurity, generateAccessToken, generateScope, parseAccessToken } from "../../../src/security/accessTokenService"
+import { entrepriseStatusEventFactory, roleManagementEventFactory, saveEntrepriseUserTest } from "@tests/utils/user.utils"
+
+import {
+ IUser2ForAccessToken,
+ SchemaWithSecurity,
+ UserForAccessToken,
+ generateAccessToken,
+ generateScope,
+ parseAccessToken,
+ user2ToUserForToken,
+} from "../../../src/security/accessTokenService"
import { useMongo } from "../../utils/mongo.utils"
-import { createUserRecruteurTest } from "../../utils/user.utils"
describe("accessTokenService", () => {
- let userACTIVE: IUserRecruteur
- let userPENDING: IUserRecruteur
- let userDISABLED: IUserRecruteur
- let userERROR: IUserRecruteur
+ let userACTIVE: IUser2ForAccessToken
+ let userPENDING: IUser2ForAccessToken
+ let userDISABLED: IUser2ForAccessToken
+ let userERROR: IUser2ForAccessToken
let userCFA
let userLbaCompany
+ const saveEntrepriseUserWithStatus = async (status: AccessStatus) => {
+ const result = await saveEntrepriseUserTest(
+ {},
+ {
+ status: [
+ roleManagementEventFactory({
+ status,
+ }),
+ ],
+ }
+ )
+ return result.user
+ }
+
const mockData = async () => {
- userACTIVE = await createUserRecruteurTest({ type: ENTREPRISE }, ETAT_UTILISATEUR.VALIDE)
- userPENDING = await createUserRecruteurTest({ type: ENTREPRISE }, ETAT_UTILISATEUR.ATTENTE)
- userDISABLED = await createUserRecruteurTest({ type: ENTREPRISE }, ETAT_UTILISATEUR.DESACTIVE)
- userERROR = await createUserRecruteurTest({ type: ENTREPRISE }, ETAT_UTILISATEUR.ERROR)
+ userACTIVE = user2ToUserForToken(await saveEntrepriseUserWithStatus(AccessStatus.GRANTED))
+ userPENDING = user2ToUserForToken(await saveEntrepriseUserWithStatus(AccessStatus.AWAITING_VALIDATION))
+ userDISABLED = user2ToUserForToken(await saveEntrepriseUserWithStatus(AccessStatus.DENIED))
+ userERROR = user2ToUserForToken(
+ (
+ await saveEntrepriseUserTest(
+ {},
+ {},
+ {
+ status: [entrepriseStatusEventFactory({ status: EntrepriseStatus.ERROR })],
+ }
+ )
+ ).user
+ )
userCFA = { type: "cfa" as const, email: "plop@gmail.com", siret: "12343154300012" }
userLbaCompany = { type: "lba-company", email: "plop@gmail.com", siret: "12343154300012" }
}
@@ -52,11 +86,11 @@ describe("accessTokenService", () => {
skip: "3",
},
} as const
- const expectTokenValid = (token: string) => expect(parseAccessToken(token, schema, options.params, options.querystring)).toBeTruthy()
+ const expectTokenValid = (token: string) => expect(parseAccessToken(token, schema, options.params, options.querystring)).resolves.toBeTruthy()
const expectTokenInvalid = (token: string) => expect(() => parseAccessToken(token, schema, options.params, options.querystring)).rejects.toThrow()
describe("valid tokens", () => {
- describe.each([
+ describe.each<[string, () => UserForAccessToken]>([
["ACTIVE user", () => userACTIVE],
["CFA", () => userCFA],
["LBA COMPANY user", () => userLbaCompany],
@@ -90,7 +124,7 @@ describe("accessTokenService", () => {
})
})
describe("invalid tokens", () => {
- describe.each([
+ describe.each<[string, () => UserForAccessToken]>([
["ERROR user", () => userERROR],
["PENDING user", () => userPENDING],
["DISABLED user", () => userDISABLED],
diff --git a/server/tests/unit/security/authorisationService.test.ts b/server/tests/unit/security/authorisationService.test.ts
index 3497d3bc77..239c874e2e 100644
--- a/server/tests/unit/security/authorisationService.test.ts
+++ b/server/tests/unit/security/authorisationService.test.ts
@@ -1,1989 +1,217 @@
import { FastifyRequest } from "fastify"
-import { ObjectId } from "mongodb"
-import { LBA_ITEM_TYPE } from "shared/constants/lbaitem"
-import { ETAT_UTILISATEUR } from "shared/constants/recruteur"
-import { IApplication, ICredential, IJob, IRecruiter, IUserRecruteur } from "shared/models"
-import { SecurityScheme } from "shared/routes/common.routes"
-import { AccessPermission, AccessRessouces, Permission, UserWithType } from "shared/security/permissions"
+import { IUser2 } from "shared/models/user2.model"
+import { AuthStrategy, IRouteSchema, WithSecurityScheme } from "shared/routes/common.routes"
+import { AccessRessouces } from "shared/security/permissions"
import { describe, expect, it } from "vitest"
-import { Application, Recruiter, UserRecruteur } from "@/common/model"
-import { IAccessToken, generateScope } from "@/security/accessTokenService"
+import { AccessUser2, AccessUserCredential, AccessUserToken } from "@/security/authenticationService"
import { authorizationMiddleware } from "@/security/authorisationService"
import { useMongo } from "@tests/utils/mongo.utils"
-
-import { createApplicationTest, createCredentialTest, createRecruteurTest, createUserRecruteurTest } from "../../utils/user.utils"
-
-describe("authorisationService", () => {
- let adminUser: IUserRecruteur
- let opcoUserO1U1: IUserRecruteur
- let opcoUserO1U2: IUserRecruteur
- let cfaUser1: IUserRecruteur
- let cfaUser2: IUserRecruteur
- let recruteurUserO1E1R1: IUserRecruteur
- let recruteurO1E1R1: IRecruiter
- let recruteurUserO1E1R2: IUserRecruteur
- let recruteurO1E1R2: IRecruiter
- let recruteurUserO1E2R1: IUserRecruteur
- let recruteurO1E2R1: IRecruiter
- let opcoUserO2U1: IUserRecruteur
- let recruteurUserO2E1R1: IUserRecruteur
- let recruteurO2E1R1: IRecruiter
- let recruteurUserO2E1R1P: IUserRecruteur
- let recruteurO2E1R1P: IRecruiter
- let credentialO1: ICredential
- let applicationO1E1R1J1A1: IApplication
- let applicationO1E1R1J1A2: IApplication
- let applicationO1E1R1J2A1: IApplication
- let applicationO1E1R2J1A1: IApplication
-
- function getResourceAccessKey(resource: IUserRecruteur | IRecruiter | IJob | IApplication, i: number): string {
- if (resource instanceof UserRecruteur) {
- return `userId${i}`
- }
- if (resource instanceof Recruiter) {
- return `recruiterId${i}`
- }
- if (resource instanceof Application) {
- return `applicationId${i}`
- }
-
- return `jobId${i}`
- }
-
- function generateSecuritySchemeFixture(
- access: AccessPermission,
- resources: ReadonlyArray,
- location: "params" | "query"
- ): [SecurityScheme, Pick] {
- return [
- {
- auth: "cookie-session",
- access,
- resources: resources.reduce((acc, resource, i) => {
- const key = getResourceAccessKey(resource, i)
- if (resource instanceof UserRecruteur) {
- const user = acc.user ?? []
- acc.user = [...user, { _id: { type: location, key } }]
- return {
- ...acc,
- user: [...user, { _id: { type: location, key } }],
- }
- }
- if (resource instanceof Recruiter) {
- const recruiter = acc.recruiter ?? []
- return {
- ...acc,
- recruiter: [...recruiter, { _id: { type: location, key } }],
- }
- }
- if (resource instanceof Application) {
- const application = acc.application ?? []
- return {
- ...acc,
- application: [...application, { _id: { type: location, key } }],
- }
- }
- const job = acc.job ?? []
- return {
- ...acc,
- job: [...job, { _id: { type: location, key } }],
+import { saveAdminUserTest, saveCfaUserTest, saveEntrepriseUserTest, saveOpcoUserTest } from "@tests/utils/user.utils"
+
+type MockedRequest = Pick
+const emptyRequest: MockedRequest = { params: {}, query: {} }
+
+type ResourceType = keyof AccessRessouces
+
+const givenARoute = ({
+ authStrategy,
+ resourceType,
+ hasRequestedAccess = true,
+}: {
+ authStrategy: AuthStrategy
+ resourceType?: ResourceType
+ hasRequestedAccess?: boolean
+}): Pick & WithSecurityScheme => {
+ // TODO formationCatalogue don't have an _id
+ return {
+ method: "get",
+ path: "/path",
+ securityScheme: {
+ access: hasRequestedAccess ? "recruiter:manage" : null,
+ auth: authStrategy,
+ resources: resourceType
+ ? {
+ [resourceType]: [{ _id: { type: "query", key: "resourceId" } }],
}
- }, {} as AccessRessouces),
- },
- resources.reduce(
- (acc, resource, i) => {
- const p = acc[location] ?? {}
- p[getResourceAccessKey(resource, i)] = resource._id
- acc[location] = p
-
- return acc
- },
- {} as Record<"params" | "query", Record>
- ),
- ]
+ : {},
+ },
}
-
- const mockData = async () => {
- // Here is the overall relation we will use to test permissions
-
- // CfaUser #1
- // CfaUser #2
- // OPCO #O1
- // |--- OpcoUser #O1#U1
- // |--- OpcoUser #O1#U2
- // |--- Entreprise #O1#E1
- // --> Recruteur #O1#E1#R1 --> Delegated #01#U3
- // ——> Recruteur pending validation #O2#E1#R1#P
- // --> Job #O1#E1#R1#J1
- // --> Application #O1#E1#R1#J1#A1
- // --> Application #O1#E1#R1#J1#A2
- // --> Job #O1#E1#R1#J2
- // --> Application #O1#E1#R1#J2#A1
- // --> Recruteur #O1#E1#R2
- // --> Job #O1#E1#R2#J1
- // --> Application #O1#E1#R2#J1#A1
- // --> Job #O1#E1#R2#J2
-
- // |--- Entreprise #O1#E2
- // --> Recruteur #O1#E2#R1
- // --> Job #O1#E2#R1#J1
- // OPCO #O2
- // |--- Entreprise #O2#E1
- // --> Recruteur #O2#E1#R1
- // --> Job #O2#E1#R1#J1
- // |--- OpcoUser #O2#U1
-
- const CFA_SIRET = "80300515600044"
- const O1E1R1J1Id = new ObjectId()
- const O1E1R1J2Id = new ObjectId()
- const O1E1R2J1Id = new ObjectId()
- const O1E1Siret = "88160687500014"
- const O1E2Siret = "38959133000060"
-
- adminUser = await createUserRecruteurTest({
- type: "ADMIN",
- })
-
- opcoUserO1U1 = await createUserRecruteurTest({
- type: "OPCO",
- scope: "#O1",
- first_name: "O1U1",
- })
- opcoUserO1U2 = await createUserRecruteurTest({
- type: "OPCO",
- scope: "#O1",
- first_name: "O1U2",
- })
- cfaUser1 = await createUserRecruteurTest({
- type: "CFA",
- first_name: "O1",
- establishment_siret: CFA_SIRET,
- })
-
- recruteurUserO1E1R1 = await createUserRecruteurTest({
- type: "ENTREPRISE",
- opco: "#O1",
- establishment_id: "#O1#E1#R1",
- establishment_siret: O1E1Siret,
- })
- recruteurO1E1R1 = await createRecruteurTest(
- {
- opco: "#O1",
- establishment_id: "#O1#E1#R1",
- cfa_delegated_siret: CFA_SIRET,
- establishment_siret: O1E1Siret,
- },
- [
- { _id: O1E1R1J1Id, job_description: "#O1#E1#R1#J1" },
- { _id: O1E1R1J2Id, job_description: "#O1#E1#R1#J2" },
- ]
- )
- applicationO1E1R1J1A1 = await createApplicationTest({
- job_id: O1E1R1J1Id.toString(),
- job_origin: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA,
- applicant_message_to_company: "#O1#E1#R1#J1#A1",
- })
- applicationO1E1R1J1A2 = await createApplicationTest({
- job_id: O1E1R1J1Id.toString(),
- job_origin: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA,
- applicant_message_to_company: "#O1#E1#R1#J1#A2",
- })
- applicationO1E1R1J2A1 = await createApplicationTest({
- job_id: O1E1R1J2Id.toString(),
- job_origin: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA,
- applicant_message_to_company: "#O1#E1#R1#J2#A1",
- })
-
- recruteurUserO1E1R2 = await createUserRecruteurTest({
- type: "ENTREPRISE",
- opco: "#O1",
- establishment_id: "#O1#E1#R2",
- establishment_siret: O1E1Siret,
- })
- recruteurO1E1R2 = await createRecruteurTest(
- {
- opco: "#O1",
- establishment_id: "#O1#E1#R2",
- establishment_siret: O1E1Siret,
- },
- [{ _id: O1E1R2J1Id, job_description: "#O1#E1#R2#J1" }]
- )
- applicationO1E1R2J1A1 = await createApplicationTest({
- job_id: O1E1R2J1Id.toString(),
- job_origin: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA,
- applicant_message_to_company: "#O1#E1#R2#J1#A1",
- })
-
- recruteurUserO1E2R1 = await createUserRecruteurTest({
- type: "ENTREPRISE",
- opco: "#O1",
- establishment_id: "#O1#E2#R1",
- establishment_siret: O1E2Siret,
- })
- recruteurO1E2R1 = await createRecruteurTest(
- {
- opco: "#O1",
- establishment_id: "#O1#E2#R1",
- establishment_siret: O1E2Siret,
+}
+
+const everyResourceType: ResourceType[] = ["application", "appointment", "formationCatalogue", "job", "recruiter", "user"]
+const everyAuthStrategy: AuthStrategy[] = ["access-token", "api-key", "cookie-session"]
+
+const givenATokenUser = (): AccessUserToken => {
+ return {
+ type: "IAccessToken",
+ value: {
+ identity: {
+ _id: "userId",
+ email: "email@mail.com",
+ type: "IUserRecruteur",
},
- [{ job_description: "#O1#E2#R1#J1" }]
- )
-
- opcoUserO2U1 = await createUserRecruteurTest({
- type: "OPCO",
- scope: "#O2",
- first_name: "O2U1",
- })
- cfaUser2 = await createUserRecruteurTest({
- type: "CFA",
- scope: "#O2",
- first_name: "O2",
- })
-
- recruteurUserO2E1R1 = await createUserRecruteurTest({
- type: "ENTREPRISE",
- scope: "#O2",
- establishment_id: "#O2#E1#R1",
- })
- recruteurO2E1R1 = await createRecruteurTest(
- {
- opco: "#O2",
- establishment_id: "#O2#E1#R1",
- },
- [{ job_description: "#O2#E1#R1#J1" }]
- )
-
- recruteurUserO2E1R1P = await createUserRecruteurTest(
- {
- type: "ENTREPRISE",
- scope: "#O2",
- establishment_id: "#O2#E1#R1P",
- },
- ETAT_UTILISATEUR.ATTENTE
- )
- recruteurO2E1R1P = await createRecruteurTest(
- {
- opco: "#O2",
- establishment_id: "#O2#E1#R1P",
- },
- [{ job_description: "#O2#E1#R1#J1P" }]
- )
-
- credentialO1 = await createCredentialTest({
- organisation: "#O1",
- })
+ scopes: [],
+ },
+ }
+}
+// const givenACredentialUser = (): AccessUserCredential => {
+// return {
+// type: "ICredential",
+// value: {},
+// }
+// }
+
+const givenARequest = ({ user, resourceId }: { user: AccessUserToken | AccessUserCredential | AccessUser2; resourceId?: string }) => {
+ return {
+ ...emptyRequest,
+ user,
+ ...(resourceId ? { query: { resourceId } } : {}),
+ }
+}
+
+describe("authorisationService", async () => {
+ let adminUser: Awaited>
+ let entrepriseUserA: Awaited>
+ let entrepriseUserB: Awaited>
+ let cfaUserA: Awaited>
+ let cfaUserB: Awaited>
+ let opcoUserA: Awaited>
+
+ useMongo(async () => {
+ adminUser = await saveAdminUserTest()
+ entrepriseUserA = await saveEntrepriseUserTest()
+ entrepriseUserB = await saveEntrepriseUserTest()
+ cfaUserA = await saveCfaUserTest()
+ cfaUserB = await saveCfaUserTest()
+ opcoUserA = await saveOpcoUserTest()
+ }, "beforeAll")
+
+ const givenACookieUser = (user: IUser2): AccessUser2 => {
+ return {
+ type: "IUser2",
+ value: user,
+ }
}
- useMongo(mockData, "beforeAll")
-
- describe.each<["params" | "query"]>([["params"], ["query"]])("when resources are identified in %s", (location) => {
- describe("as an admin user", () => {
- describe.each<[Permission]>([
- ["recruiter:manage"],
- ["user:validate"],
- ["recruiter:add_job"],
- ["job:manage"],
- ["school:manage"],
- ["application:manage"],
- ["user:manage"],
- ["admin"],
- ])("I have %s permission", (permission) => {
- it("on all ressources", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(
- permission,
- [
- adminUser,
- opcoUserO1U1,
- opcoUserO1U2,
- cfaUser2,
- recruteurUserO1E1R1,
- recruteurO1E1R1,
- ...recruteurO1E1R1.jobs,
- recruteurUserO1E1R2,
- recruteurO1E1R2,
- ...recruteurO1E1R2.jobs,
- recruteurUserO1E2R1,
- recruteurO1E2R1,
- ...recruteurO1E2R1.jobs,
- opcoUserO2U1,
- cfaUser1,
- recruteurUserO2E1R1,
- recruteurO2E1R1,
- ...recruteurO2E1R1.jobs,
- applicationO1E1R1J1A1,
- applicationO1E1R1J1A2,
- applicationO1E1R1J2A1,
- ],
- location
- )
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: adminUser,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- })
-
- describe("as an opco user", () => {
- describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"]])("I have %s permission", (permission) => {
- it("on all recruiters from my opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1, recruteurO1E1R2, recruteurO1E2R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["job:manage"]])("I have %s permission", (permission) => {
- it("on all jobs from my opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R1.jobs, ...recruteurO1E1R2.jobs, ...recruteurO1E2R1.jobs], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["user:manage"]])("I have %s permission", (permission) => {
- it("on myself", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["user:manage"], ["user:validate"]])("I have %s permission", (permission) => {
- it("on user recruiter from my Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
-
- describe.each<[Permission]>([["recruiter:manage"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on recruiter from other Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO2E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on jobs from other Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO2E1R1.jobs], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["school:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on school", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on application from my Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R1J1A1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["admin"]])("I do not have %s permission", (permission) => {
- it("on user recruiter from my Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["user:manage"], ["admin"], ["user:validate"]])("I do not have %s permission", (permission) => {
- it("on admin user", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["user:manage"], ["admin"], ["user:validate"]])("I do not have %s permission", (permission) => {
- it("on user CFA", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["user:manage"], ["admin"], ["user:validate"]])("I do not have %s permission", (permission) => {
- it("on other user Opco from my Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- })
-
- describe("as an opco credential", () => {
- describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"]])("I have %s permission", (permission) => {
- it("on all recruiters from my opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1, recruteurO1E1R2, recruteurO1E2R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["job:manage"]])("I have %s permission", (permission) => {
- it("on all jobs from my opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R1.jobs, ...recruteurO1E1R2.jobs, ...recruteurO1E2R1.jobs], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["user:manage"]])("I have %s permission", (permission) => {
- it("on myself", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
-
- describe.each<[Permission]>([["recruiter:manage"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on recruiter from other Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO2E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on jobs from other Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO2E1R1.jobs], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["school:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on school", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on application from my Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R1J1A1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on user recruiter from my Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on admin user", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on user CFA", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["user:manage"]])("I have %s permission", (permission) => {
- it("on user Opco from my Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
-
- describe.each<[Permission]>([["admin"]])("I do not have %s permission", (permission) => {
- it("on user Opco from my Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "ICredential",
- value: credentialO1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- })
-
- describe("as a cfa user", () => {
- describe.each<[Permission]>([["recruiter:manage"], ["recruiter:add_job"]])("I have %s permission", (permission) => {
- it("on all my delegated recruiters", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["job:manage"]])("I have %s permission", (permission) => {
- it("on all jobs from my delegated recruiters", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R1.jobs], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["user:manage"], ["school:manage"]])("I have %s permission", (permission) => {
- it("on myself", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
-
- describe.each<[Permission]>([["user:validate"]])("I do not have %s permission", (permission) => {
- it("on all my delegated recruiters", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on non delegated recruiters", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on non delegated recruiters", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R2.jobs], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["school:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on other schools", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on application of my delegated recruiter", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R1J1A1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
-
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on user recruiter", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
+ const givenAnAdminUser = (): AccessUser2 => {
+ return givenACookieUser(adminUser.user)
+ }
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on admin user", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
+ describe("authorizationMiddleware", () => {
+ describe.each(everyAuthStrategy)("given a route expecting a %s identity type", (authStrategy) => {
+ it("should allow to call a route without requested access", async () => {
+ await expect(authorizationMiddleware(givenARoute({ authStrategy, hasRequestedAccess: false }), emptyRequest)).resolves.toBe(undefined)
})
-
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on user Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: cfaUser1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
+ it("should reject an unidentified user", async () => {
+ await expect(authorizationMiddleware(givenARoute({ authStrategy }), emptyRequest)).rejects.toThrow("User should be authenticated")
})
})
-
- describe("as a recruiter user", () => {
- describe.each<[Permission]>([["recruiter:manage"], ["recruiter:add_job"]])("I have %s permission", (permission) => {
- it("on me", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["job:manage"]])("I have %s permission", (permission) => {
- it("on my jobs", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R1.jobs], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["application:manage"]])("I have %s permission", (permission) => {
- it("on my applications", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R1J1A1, applicationO1E1R1J1A2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["user:manage"]])("I have %s permission", (permission) => {
- it("on myself", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["user:validate"]])("I do not have %s permission", (permission) => {
- it("on me", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on other recruiters from my company", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on job from other recruiters from my company", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R2.jobs], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on other applications", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R2J1A1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["user:manage"], ["school:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on school", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["user:manage"]])("I do not have %s permission", (permission) => {
- it("on other user recruiter", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on admin user", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
+ describe.each(everyResourceType)("given an accessed resource of type %s", (resourceType) => {
+ it("should always allow a token user because authorization has been dealt with in the authentication layer", async () => {
+ await expect(
+ authorizationMiddleware(givenARoute({ authStrategy: "access-token", resourceType }), givenARequest({ user: givenATokenUser(), resourceId: "resourceId" }))
+ ).resolves.toBe(undefined)
})
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on user Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
+ it("should always allow an admin user", async () => {
+ await expect(
+ authorizationMiddleware(givenARoute({ authStrategy: "cookie-session", resourceType }), givenARequest({ user: givenAnAdminUser(), resourceId: "resourceId" }))
+ ).resolves.toBe(undefined)
})
})
- describe("as a pending recruiter user", () => {
- describe.each<[Permission]>([["recruiter:add_job"]])("I have %s permission", (permission) => {
- it("on me", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO2E1R1P], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO2E1R1P,
- },
- ...req,
- }
- )
- ).resolves.toBe(undefined)
- })
- })
- describe.each<[Permission]>([["user:validate"]])("I do not have %s permission", (permission) => {
- it("on me", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO2E1R1P], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO2E1R1P,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on other recruiters from my company", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO2E1R1P,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on job from other recruiters from my company", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R2.jobs], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO2E1R1P,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on other applications", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R2J1A1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO2E1R1P,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["user:manage"], ["school:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on school", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO2E1R1P,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["user:manage"]])("I do not have %s permission", (permission) => {
- it("on other user recruiter", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R2], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO2E1R1P,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
- })
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on admin user", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO2E1R1P,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
+ describe("user access", () => {
+ it("an entreprise user should have access to its user", async () => {
+ const user = entrepriseUserA.user
+ await expect(
+ authorizationMiddleware(
+ givenARoute({ authStrategy: "cookie-session", resourceType: "user" }),
+ givenARequest({ user: givenACookieUser(user), resourceId: user._id.toString() })
+ )
+ ).resolves.toBe(undefined)
})
- describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => {
- it("on user Opco", async () => {
- const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO2E1R1P,
- },
- ...req,
- }
- )
- ).rejects.toThrow("Forbidden")
- })
+ it("an entreprise user should NOT have access to another user", async () => {
+ const user = entrepriseUserA.user
+ const accessedUser = cfaUserA.user
+ await expect(
+ authorizationMiddleware(
+ givenARoute({ authStrategy: "cookie-session", resourceType: "user" }),
+ givenARequest({ user: givenACookieUser(user), resourceId: accessedUser._id.toString() })
+ )
+ ).rejects.toThrow("non autorisé")
})
- })
- })
-
- it("should support retrieving recruiter resource per establishment_id", async () => {
- const securityScheme: SecurityScheme = {
- auth: "cookie-session",
- access: "recruiter:manage",
- resources: {
- recruiter: [
- {
- establishment_id: {
- type: "query",
- key: "establishment_id",
- },
- },
- ],
- },
- }
-
- const query = {
- establishment_id: recruteurO1E1R1.establishment_id,
- }
-
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- query,
- params: {},
- }
- )
- ).resolves.toBe(undefined)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R2,
- },
- query,
- params: {},
- }
- )
- ).rejects.toThrow("Forbidden")
- })
-
- it("should support some operator permission", async () => {
- const securityScheme: SecurityScheme = {
- auth: "cookie-session",
- access: { some: ["recruiter:manage", "user:validate"] },
- resources: {
- recruiter: [
- {
- establishment_id: {
- type: "query",
- key: "establishment_id",
- },
- },
- ],
- },
- }
-
- const query = {
- establishment_id: recruteurO1E1R1.establishment_id,
- }
-
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- query,
- params: {},
- }
- )
- ).resolves.toBe(undefined)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R2,
- },
- query,
- params: {},
- }
- )
- ).rejects.toThrow("Forbidden")
- })
-
- it("should support every operator permission", async () => {
- const securityScheme: SecurityScheme = {
- auth: "cookie-session",
- access: { every: ["recruiter:manage", "user:validate"] },
- resources: {
- recruiter: [
- {
- establishment_id: {
- type: "query",
- key: "establishment_id",
- },
- },
- ],
- },
- }
-
- const query = {
- establishment_id: recruteurO1E1R1.establishment_id,
- }
-
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- query,
- params: {},
- }
- )
- ).resolves.toBe(undefined)
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: recruteurUserO1E1R1,
- },
- query,
- params: {},
- }
- )
- ).rejects.toThrow("Forbidden")
- })
-
- it("should support null access", async () => {
- const securityScheme: SecurityScheme = {
- auth: "cookie-session",
- access: null,
- resources: {},
- }
-
- await expect(
- authorizationMiddleware(
- {
- method: "get",
- path: "/path",
- securityScheme,
- },
- {
- user: {
- type: "IUserRecruteur",
- value: opcoUserO1U1,
- },
- query: {},
- params: {},
- }
- )
- ).resolves.toBe(undefined)
- })
-
- describe("with access token", () => {
- describe("when accessing recruiter resource", () => {
- it("should allow when resource is present in token for same scope", async () => {
- const securityScheme: SecurityScheme = {
- auth: "cookie-session",
- access: "recruiter:manage",
- resources: {
- recruiter: [{ _id: { type: "params", key: "id" } }],
- },
- }
- const userWithType: UserWithType<"IAccessToken", IAccessToken> = {
- type: "IAccessToken",
- value: {
- identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" },
- scopes: [
- generateScope({
- schema: {
- method: "post",
- path: "/path/:id",
- securityScheme: {
- auth: "access-token",
- access: null,
- resources: {},
- },
- },
- options: "all",
- }),
- generateScope({
- schema: {
- method: "get",
- path: "/path/:id",
- securityScheme: {
- auth: "access-token",
- access: null,
- resources: {},
- },
- },
- options: "all",
- }),
- ],
- },
- }
-
+ it("a cfa user should have access to its user", async () => {
+ const user = cfaUserA.user
await expect(
authorizationMiddleware(
- {
- method: "get",
- path: "/path/:id",
- securityScheme,
- },
- {
- user: userWithType,
- query: {},
- params: {
- id: recruteurO1E1R1._id.toString(),
- },
- }
+ givenARoute({ authStrategy: "cookie-session", resourceType: "user" }),
+ givenARequest({ user: givenACookieUser(user), resourceId: user._id.toString() })
)
).resolves.toBe(undefined)
})
- })
- describe("when accessing job resource", () => {
- it("should allow when resource is present in token for same scope", async () => {
- const securityScheme: SecurityScheme = {
- auth: "cookie-session",
- access: "job:manage",
- resources: {
- job: [{ _id: { type: "params", key: "id" } }],
- },
- }
- const userWithType: UserWithType<"IAccessToken", IAccessToken> = {
- type: "IAccessToken",
- value: {
- identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" },
- scopes: [
- generateScope({
- schema: {
- method: "post",
- path: "/path/:id",
- securityScheme: {
- auth: "access-token",
- access: null,
- resources: {},
- },
- },
- options: "all",
- }),
- generateScope({
- schema: {
- method: "get",
- path: "/path/:id",
- securityScheme: {
- auth: "access-token",
- access: null,
- resources: {},
- },
- },
- options: "all",
- }),
- ],
- },
- }
-
+ it("an opco user should have access to its user", async () => {
+ const user = opcoUserA.user
await expect(
authorizationMiddleware(
- {
- method: "get",
- path: "/path/:id",
- securityScheme,
- },
- {
- user: userWithType,
- query: {},
- params: {
- id: recruteurO1E1R1.jobs.map((j) => j._id.toString())[0],
- },
- }
+ givenARoute({ authStrategy: "cookie-session", resourceType: "user" }),
+ givenARequest({ user: givenACookieUser(user), resourceId: user._id.toString() })
)
).resolves.toBe(undefined)
})
})
- describe("when accessing application resource", () => {
- it("should allow when resource is present in token for same scope", async () => {
- const securityScheme: SecurityScheme = {
- auth: "cookie-session",
- access: "application:manage",
- resources: {
- application: [{ _id: { type: "params", key: "id" } }],
- },
- }
- const userWithType: UserWithType<"IAccessToken", IAccessToken> = {
- type: "IAccessToken",
- value: {
- identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" },
- scopes: [
- generateScope({
- schema: {
- method: "post",
- path: "/path/:id",
- securityScheme: {
- auth: "access-token",
- access: null,
- resources: {},
- },
- },
- options: "all",
- }),
- generateScope({
- schema: {
- method: "get",
- path: "/path/:id",
- securityScheme: {
- auth: "access-token",
- access: null,
- resources: {},
- },
- },
- options: "all",
- }),
- ],
- },
- }
-
+ describe("job access", () => {
+ it("an entreprise user should have access to its jobs", async () => {
+ const { user, recruiter } = entrepriseUserA
+ const [job] = recruiter.jobs
await expect(
authorizationMiddleware(
- {
- method: "get",
- path: "/path/:id",
- securityScheme,
- },
- {
- user: userWithType,
- query: {},
- params: {
- id: applicationO1E1R1J1A1._id.toString(),
- },
- }
+ givenARoute({ authStrategy: "cookie-session", resourceType: "job" }),
+ givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() })
)
).resolves.toBe(undefined)
})
- })
- describe("when accessing user resource", () => {
- it("should allow when resource is present in token for same scope", async () => {
- const securityScheme: SecurityScheme = {
- auth: "cookie-session",
- access: "user:manage",
- resources: {
- user: [{ _id: { type: "params", key: "id" } }],
- },
- }
- const userWithType: UserWithType<"IAccessToken", IAccessToken> = {
- type: "IAccessToken",
- value: {
- identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" },
- scopes: [
- generateScope({
- schema: {
- method: "post",
- path: "/path/:id",
- securityScheme: {
- auth: "access-token",
- access: null,
- resources: {},
- },
- },
- options: "all",
- }),
- generateScope({
- schema: {
- method: "get",
- path: "/path/:id",
- securityScheme: {
- auth: "access-token",
- access: null,
- resources: {},
- },
- },
- options: "all",
- }),
- ],
- },
- }
-
+ it("an entreprise user should NOT have access to another entreprise's jobs", async () => {
+ const user = entrepriseUserA.user
+ const { recruiter } = entrepriseUserB
+ const [job] = recruiter.jobs
await expect(
authorizationMiddleware(
- {
- method: "get",
- path: "/path/:id",
- securityScheme,
- },
- {
- user: userWithType,
- query: {},
- params: {
- id: opcoUserO1U1._id.toString(),
- },
- }
+ givenARoute({ authStrategy: "cookie-session", resourceType: "job" }),
+ givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() })
+ )
+ ).rejects.toThrow("non autorisé")
+ })
+ it("a cfa user should have access to its jobs", async () => {
+ const { user, recruiter } = cfaUserA
+ const [job] = recruiter.jobs
+ await expect(
+ authorizationMiddleware(
+ givenARoute({ authStrategy: "cookie-session", resourceType: "job" }),
+ givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() })
)
).resolves.toBe(undefined)
})
+ it("a cfa user should NOT have access to another cfa's job", async () => {
+ const user = cfaUserA.user
+ const { recruiter } = cfaUserB
+ const [job] = recruiter.jobs
+ await expect(
+ authorizationMiddleware(
+ givenARoute({ authStrategy: "cookie-session", resourceType: "job" }),
+ givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() })
+ )
+ ).rejects.toThrow("non autorisé")
+ })
+ it("a cfa user should NOT have access to another entreprise job", async () => {
+ const user = cfaUserA.user
+ const { recruiter } = entrepriseUserA
+ const [job] = recruiter.jobs
+ await expect(
+ authorizationMiddleware(
+ givenARoute({ authStrategy: "cookie-session", resourceType: "job" }),
+ givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() })
+ )
+ ).rejects.toThrow("non autorisé")
+ })
})
})
})
diff --git a/server/tests/utils/login.utils.ts b/server/tests/utils/login.utils.ts
index 118e20f410..82208eb661 100644
--- a/server/tests/utils/login.utils.ts
+++ b/server/tests/utils/login.utils.ts
@@ -1,18 +1,28 @@
-import { ETAT_UTILISATEUR } from "shared/constants/recruteur"
-import { IUserRecruteur } from "shared/models"
+import { IUser2 } from "shared/models/user2.model"
-import { UserRecruteur } from "@/common/model"
import { Server } from "@/http/server"
+import { user2ToUserForToken } from "@/security/accessTokenService"
import { createAuthMagicLinkToken } from "@/services/appLinks.service"
-export const createAndLogUser = async (httpClient: () => Server, username: string, options: Partial = {}) => {
+import { saveAdminUserTest, saveCfaUserTest } from "./user.utils"
+
+export const createAndLogUser = async (httpClient: () => Server, username: string, { type }: { type: "CFA" | "ADMIN" }) => {
const email = `${username.toLowerCase()}@mail.com`
- const user = await UserRecruteur.create({ username, email, first_name: "first name", last_name: "last name", status: [{ status: ETAT_UTILISATEUR.VALIDE }], ...options })
+ let user: IUser2
+ if (type === "ADMIN") {
+ const result = await saveAdminUserTest({ email })
+ user = result.user
+ } else if (type === "CFA") {
+ const result = await saveCfaUserTest({ email })
+ user = result.user
+ } else {
+ throw new Error(`Unsupported type ${type}`)
+ }
const response = await httpClient().inject({
method: "POST",
path: "/api/login/verification",
- headers: { authorization: `Bearer ${createAuthMagicLinkToken(user)}` },
+ headers: { authorization: `Bearer ${createAuthMagicLinkToken(user2ToUserForToken(user))}` },
})
return {
Cookie: response.cookies.reduce((acc, cookie) => `${acc} ${cookie.name}=${cookie.value}`, ""),
diff --git a/server/tests/utils/user.utils.ts b/server/tests/utils/user.utils.ts
index 0e1ae74c7b..bb9e7c2468 100644
--- a/server/tests/utils/user.utils.ts
+++ b/server/tests/utils/user.utils.ts
@@ -1,12 +1,16 @@
-import { ObjectId } from "mongodb"
-import { ETAT_UTILISATEUR } from "shared/constants/recruteur"
+import { OPCOS, RECRUITER_STATUS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur"
import { extensions } from "shared/helpers/zodHelpers/zodPrimitives"
-import { IApplication, ICredential, IEmailBlacklist, IJob, IRecruiter, IUserRecruteur, ZApplication, ZCredential, ZEmailBlacklist, ZRecruiter, ZUserRecruteur } from "shared/models"
+import { IApplication, ICredential, IEmailBlacklist, IJob, IRecruiter, JOB_STATUS, ZApplication, ZCredential, ZEmailBlacklist } from "shared/models"
+import { ICFA, zCFA } from "shared/models/cfa.model"
import { zObjectId } from "shared/models/common"
-import { ZodObject, ZodString } from "zod"
+import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent, ZEntreprise } from "shared/models/entreprise.model"
+import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model"
+import { IUser2, ZUser2 } from "shared/models/user2.model"
+import { ZodObject, ZodString, ZodTypeAny } from "zod"
import { Fixture, Generator } from "zod-fixture"
-import { Application, Credential, EmailBlacklist, Recruiter, UserRecruteur } from "@/common/model"
+import { Application, Cfa, Credential, EmailBlacklist, Entreprise, Recruiter, RoleManagement, User2 } from "@/common/model"
+import { ObjectId } from "@/common/mongodb"
let seed = 0
function getFixture() {
@@ -48,16 +52,105 @@ function getFixture() {
])
}
-export async function createUserRecruteurTest(data: Partial, userState: string = ETAT_UTILISATEUR.VALIDE) {
- const u = new UserRecruteur({
- ...getFixture().fromSchema(ZUserRecruteur),
- status: [{ validation_type: "AUTOMATIQUE", status: userState }],
+export const saveDbEntity = async (schema: ZodTypeAny, dbModel: (item: T) => { save: () => Promise } & T, data: Partial) => {
+ const u = dbModel({
+ ...getFixture().fromSchema(schema),
...data,
})
await u.save()
return u
}
+export const saveUser2 = async (data: Partial = {}) => {
+ return saveDbEntity(ZUser2, (item) => new User2(item), data)
+}
+export const saveRoleManagement = async (data: Partial = {}) => {
+ const role: IRoleManagement = {
+ _id: new ObjectId(),
+ authorized_id: "id",
+ authorized_type: AccessEntityType.CFA,
+ createdAt: new Date(),
+ origin: "origin",
+ status: [],
+ updatedAt: new Date(),
+ user_id: new ObjectId(),
+ ...data,
+ }
+ await new RoleManagement(role).save()
+ return role
+}
+
+export const roleManagementEventFactory = ({
+ date = new Date(),
+ granted_by,
+ reason = "reason",
+ status = AccessStatus.GRANTED,
+ validation_type = VALIDATION_UTILISATEUR.AUTO,
+}: Partial = {}): IRoleManagementEvent => {
+ return {
+ date,
+ granted_by,
+ reason,
+ status,
+ validation_type,
+ }
+}
+
+export const saveEntreprise = async (data: Partial = {}) => {
+ return saveDbEntity(ZEntreprise, (item) => new Entreprise(item), data)
+}
+
+export const entrepriseStatusEventFactory = (props: Partial = {}): IEntrepriseStatusEvent => {
+ return {
+ date: new Date(),
+ reason: "test",
+ status: EntrepriseStatus.VALIDE,
+ validation_type: VALIDATION_UTILISATEUR.AUTO,
+ ...props,
+ }
+}
+
+export const saveCfa = async (data: Partial = {}) => {
+ return saveDbEntity(zCFA, (item) => new Cfa(item), data)
+}
+
+export const jobFactory = (props: Partial = {}) => {
+ const job: IJob = {
+ _id: new ObjectId(),
+ rome_label: "rome_label",
+ rome_appellation_label: "rome_appellation_label",
+ job_level_label: "job_level_label",
+ job_start_date: new Date(),
+ job_description: "job_description",
+ job_employer_description: "job_employer_description",
+ rome_code: ["rome_code"],
+ rome_detail: null,
+ job_creation_date: new Date(),
+ job_expiration_date: new Date(),
+ job_update_date: new Date(),
+ job_last_prolongation_date: new Date(),
+ job_prolongation_count: 0,
+ relance_mail_sent: false,
+ job_status: JOB_STATUS.ACTIVE,
+ job_status_comment: "job_status_comment",
+ job_type: ["Apprentissage"],
+ is_multi_published: false,
+ job_delegation_count: 0,
+ delegations: [],
+ is_disabled_elligible: false,
+ job_count: 1,
+ job_duration: 6,
+ job_rythm: "job_rythm",
+ custom_address: "custom_address",
+ custom_geo_coordinates: "custom_geo_coordinates",
+ stats_detail_view: 0,
+ stats_search_view: 0,
+ managed_by: new ObjectId(),
+ ...props,
+ }
+ return job
+}
+
export async function createCredentialTest(data: Partial) {
const u = new Credential({
...getFixture().fromSchema(ZCredential),
@@ -67,17 +160,41 @@ export async function createCredentialTest(data: Partial) {
return u
}
-export async function createRecruteurTest(data: Partial, jobsData: Partial[]) {
- const u = new Recruiter({
- ...getFixture().fromSchema(ZRecruiter),
+export async function saveRecruiter(data: Partial) {
+ const recruiter: IRecruiter = {
+ _id: new ObjectId(),
+ distance: 10,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ establishment_id: "establishment_id",
+ establishment_raison_sociale: "establishment_raison_sociale",
+ establishment_enseigne: "establishment_enseigne",
+ establishment_siret: "establishment_siret",
+ address_detail: "address_detail",
+ address: "address",
+ geo_coordinates: "geo_coordinates",
+ geopoint: {
+ type: "Point",
+ coordinates: [41, 10],
+ },
+ is_delegated: false,
+ cfa_delegated_siret: "cfa_delegated_siret",
+ last_name: "last_name",
+ first_name: "first_name",
+ phone: "phone",
+ email: "email",
+ jobs: [],
+ origin: "origin",
+ opco: "opco",
+ idcc: "idcc",
+ status: RECRUITER_STATUS.ACTIF,
+ naf_code: "naf_code",
+ naf_label: "naf_label",
+ establishment_size: "establishment_size",
+ establishment_creation_date: new Date(),
...data,
- jobs: jobsData.map((d) => {
- return {
- ...getFixture().fromSchema(ZRecruiter),
- ...d,
- }
- }),
- })
+ }
+ const u = new Recruiter(recruiter)
await u.save()
return u
}
@@ -99,3 +216,72 @@ export async function createEmailBlacklistTest(data: Partial) {
await u.save()
return u
}
+
+export const saveAdminUserTest = async (userProps: Partial = {}) => {
+ const user = await saveUser2(userProps)
+ const role = await saveRoleManagement({
+ user_id: user._id,
+ authorized_type: AccessEntityType.ADMIN,
+ authorized_id: undefined,
+ status: [roleManagementEventFactory()],
+ })
+ return { user, role }
+}
+
+export const saveEntrepriseUserTest = async (userProps: Partial = {}, roleProps: Partial = {}, entrepriseProps: Partial = {}) => {
+ const user = await saveUser2(userProps)
+ const entreprise = await saveEntreprise(entrepriseProps)
+ const role = await saveRoleManagement({
+ user_id: user._id,
+ authorized_id: entreprise._id.toString(),
+ authorized_type: AccessEntityType.ENTREPRISE,
+ status: [roleManagementEventFactory()],
+ ...roleProps,
+ })
+ const recruiter = await saveRecruiter({
+ is_delegated: false,
+ cfa_delegated_siret: null,
+ status: RECRUITER_STATUS.ACTIF,
+ establishment_siret: entreprise.siret,
+ opco: entreprise.opco,
+ jobs: [
+ jobFactory({
+ managed_by: user._id,
+ }),
+ ],
+ })
+ return { user, role, entreprise, recruiter }
+}
+
+export const saveCfaUserTest = async (userProps: Partial = {}) => {
+ const user = await saveUser2(userProps)
+ const cfa = await saveCfa()
+ const role = await saveRoleManagement({
+ user_id: user._id,
+ authorized_id: cfa._id.toString(),
+ authorized_type: AccessEntityType.CFA,
+ status: [roleManagementEventFactory()],
+ })
+ const recruiter = await saveRecruiter({
+ is_delegated: true,
+ cfa_delegated_siret: cfa.siret,
+ status: RECRUITER_STATUS.ACTIF,
+ jobs: [
+ jobFactory({
+ managed_by: user._id,
+ }),
+ ],
+ })
+ return { user, role, cfa, recruiter }
+}
+
+export const saveOpcoUserTest = async () => {
+ const user = await saveUser2()
+ const role = await saveRoleManagement({
+ user_id: user._id,
+ authorized_id: OPCOS.AKTO,
+ authorized_type: AccessEntityType.OPCO,
+ status: [roleManagementEventFactory()],
+ })
+ return { user, role }
+}
diff --git a/shared/constants/appointment.ts b/shared/constants/appointment.ts
new file mode 100644
index 0000000000..088cd9b463
--- /dev/null
+++ b/shared/constants/appointment.ts
@@ -0,0 +1,4 @@
+export enum AppointmentUserType {
+ PARENT = "parent",
+ ETUDIENT = "etudiant",
+}
diff --git a/shared/constants/errorCodes.ts b/shared/constants/errorCodes.ts
index 2e08308412..bd64bb5198 100644
--- a/shared/constants/errorCodes.ts
+++ b/shared/constants/errorCodes.ts
@@ -1,6 +1,7 @@
export enum BusinessErrorCodes {
IS_CFA = "IS_CFA",
ALREADY_EXISTS = "ALREADY_EXISTS",
+ EMAIL_ALREADY_EXISTS = "EMAIL_ALREADY_EXISTS",
CLOSED = "CLOSED",
NON_DIFFUSIBLE = "NON_DIFFUSIBLE",
UNKNOWN = "UNKNOWN",
diff --git a/shared/constants/recruteur.ts b/shared/constants/recruteur.ts
index 839d2c4283..fd61b0f2d9 100644
--- a/shared/constants/recruteur.ts
+++ b/shared/constants/recruteur.ts
@@ -30,6 +30,7 @@ export enum ETAT_UTILISATEUR {
export const ENTREPRISE = "ENTREPRISE"
export const CFA = "CFA"
export const ADMIN = "ADMIN"
+export const OPCO = "OPCO"
export const AUTHTYPE = {
OPCO: "OPCO",
@@ -55,6 +56,7 @@ export enum OPCOS {
MOBILITE = "Opco Mobilités",
SANTE = "Opco Santé",
UNIFORMATION = "Uniformation, l'Opco de la Cohésion sociale",
+ PASS = "pass",
}
export const NIVEAUX_POUR_LBA = {
diff --git a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap
index 834a553cc6..a438f83ecf 100644
--- a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap
+++ b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap
@@ -522,6 +522,12 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = `
"contact": {
"$ref": "#/components/schemas/Contact",
},
+ "detailsLoaded": {
+ "type": [
+ "boolean",
+ "null",
+ ],
+ },
"diploma": {
"description": "Le diplôme délivré par la formation.",
"example": "CERTIFICAT D'APTITUDES PROFESSIONNELLES",
@@ -733,7 +739,7 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = `
],
},
"job_description": {
- "description": "Description de l'offre d'alternance",
+ "description": "Description de l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -749,7 +755,7 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = `
],
},
"job_employer_description": {
- "description": "Description de l'employer proposant l'offre d'alternance",
+ "description": "Description de l'employer proposant l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -848,6 +854,9 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = `
"null",
],
},
+ "managed_by": {
+ "description": "Id de l'utilisateur gérant l'offre",
+ },
"relance_mail_sent": {
"description": "Statut de l'envoi du mail de relance avant expiration",
"type": [
@@ -1088,6 +1097,12 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = `
"contact": {
"$ref": "#/components/schemas/Contact",
},
+ "detailsLoaded": {
+ "type": [
+ "boolean",
+ "null",
+ ],
+ },
"id": {
"type": [
"string",
@@ -1335,6 +1350,12 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = `
"contact": {
"$ref": "#/components/schemas/Contact",
},
+ "detailsLoaded": {
+ "type": [
+ "boolean",
+ "null",
+ ],
+ },
"diplomaLevel": {
"description": "Le niveau de la formation.",
"example": "3 (CAP...)",
@@ -1793,6 +1814,12 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = `
"contact": {
"$ref": "#/components/schemas/Contact",
},
+ "detailsLoaded": {
+ "type": [
+ "boolean",
+ "null",
+ ],
+ },
"id": {
"type": [
"string",
@@ -2131,6 +2158,9 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = `
"null",
],
},
+ "managed_by": {
+ "description": "Id de l'utilisateur gestionnaire",
+ },
"naf_code": {
"description": "Code NAF de l'établissement",
"type": [
@@ -2836,6 +2866,7 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = `
"post": {
"description": "Envoi d'un email de candidature à une offre postée sur La bonne alternance recruteur ou une candidature spontanée à une entreprise identifiée par La bonne alternance.
L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne alternance.
+Limite : 5 appel(s) / 5 seconde(s)
",
"requestBody": {
"content": {
@@ -3449,7 +3480,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/formations": {
"get": {
- "description": "Rechercher des formations en alternance pour un métier ou un ensemble de métiers autour d'un point géographique",
+ "description": "Rechercher des formations en alternance pour un métier ou un ensemble de métiers autour d'un point géographique
+Limite : 7 appel(s) / 1 seconde(s)
+",
"operationId": "getFormations",
"parameters": [
{
@@ -3635,7 +3668,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/formations/formation/{id}": {
"get": {
- "description": "Get one formation identified by it's clé ministère éducatif",
+ "description": "Get one formation identified by it's clé ministère éducatif
+Limite : 7 appel(s) / 1 seconde(s)
+",
"operationId": "getFormation",
"parameters": [
{
@@ -3747,7 +3782,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/formations/min": {
"get": {
- "description": "Rechercher des formations en alternance pour un métier ou un ensemble de métiers autour d'un point géographique. Récupération des données minimales.",
+ "description": "Rechercher des formations en alternance pour un métier ou un ensemble de métiers autour d'un point géographique. Récupération des données minimales.
+Limite : 7 appel(s) / 1 seconde(s)
+",
"operationId": "getFormations",
"parameters": [
{
@@ -3933,7 +3970,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/formationsParRegion": {
"get": {
- "description": "Rechercher des formations en alternance dans un département ou dans une région ou dans la France entière pour un métier ou un ensemble de métiers",
+ "description": "Rechercher des formations en alternance dans un département ou dans une région ou dans la France entière pour un métier ou un ensemble de métiers
+Limite : 7 appel(s) / 1 seconde(s)
+",
"operationId": "getFormations",
"parameters": [
{
@@ -4099,7 +4138,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs": {
"get": {
- "description": "Get job opportunities matching the query parameters",
+ "description": "Get job opportunities matching the query parameters
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "getJobOpportunities",
"parameters": [
{
@@ -4386,7 +4427,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/bulk": {
"get": {
- "description": "Get all jobs related to my organization",
+ "description": "Get all jobs related to my organization
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "getJobs",
"parameters": [
{
@@ -4487,7 +4530,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/canceled/{jobId}": {
"post": {
- "description": "Update a job offer status to Canceled",
+ "description": "Update a job offer status to Canceled
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "setJobAsCanceled",
"parameters": [
{
@@ -4525,7 +4570,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/delegations/{jobId}": {
"get": {
- "description": "Get related training organization related to a job offer.",
+ "description": "Get related training organization related to a job offer.
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "getDelegation",
"parameters": [
{
@@ -4700,7 +4747,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/entreprise_lba/{siret}": {
"get": {
- "description": "Get one company identified by it's siret",
+ "description": "Get one company identified by it's siret
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "getCompany",
"parameters": [
{
@@ -4831,7 +4880,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/establishment": {
"get": {
- "description": "Get existing establishment id from siret & email",
+ "description": "Get existing establishment id from siret & email
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Le numéro de SIRET de l'établissement",
@@ -4897,7 +4948,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"post": {
- "description": "Create an establishment entity",
+ "description": "Create an establishment entity
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "createEstablishment",
"requestBody": {
"content": {
@@ -4987,7 +5040,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/extend/{jobId}": {
"post": {
- "description": "Update a job expiration date by 30 days.",
+ "description": "Update a job expiration date by 30 days.
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "extendJobExpiration",
"parameters": [
{
@@ -5025,7 +5080,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/matcha/{id}/stats/view-details": {
"post": {
- "description": "Notifies that the detail of a matcha job has been viewed",
+ "description": "Notifies that the detail of a matcha job has been viewed
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "statsViewLbaJob",
"parameters": [
{
@@ -5059,7 +5116,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/min": {
"get": {
- "description": "Get job opportunities matching the query parameters and returns minimal data",
+ "description": "Get job opportunities matching the query parameters and returns minimal data
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "getJobOpportunities",
"parameters": [
{
@@ -5342,7 +5401,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/provided/{jobId}": {
"post": {
- "description": "Update a job offer status to Provided",
+ "description": "Update a job offer status to Provided
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "setJobAsProvided",
"parameters": [
{
@@ -5380,7 +5441,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/{establishmentId}": {
"post": {
- "description": "Create a job offer inside an establishment entity.",
+ "description": "Create a job offer inside an establishment entity.
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "createJob",
"parameters": [
{
@@ -5433,7 +5496,7 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"job_description": {
- "description": "Description de l'offre d'alternance",
+ "description": "Description de l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -5449,7 +5512,7 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"job_employer_description": {
- "description": "Description de l'employer proposant l'offre d'alternance",
+ "description": "Description de l'employer proposant l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -5553,7 +5616,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/{jobId}": {
"patch": {
- "description": "Update a job offer specific fields inside an establishment entity.",
+ "description": "Update a job offer specific fields inside an establishment entity.
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "updateJob",
"parameters": [
{
@@ -5602,7 +5667,7 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"job_description": {
- "description": "Description de l'offre d'alternance",
+ "description": "Description de l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -5618,7 +5683,7 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"job_employer_description": {
- "description": "Description de l'employer proposant l'offre d'alternance",
+ "description": "Description de l'employer proposant l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -5717,7 +5782,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobs/{source}/{id}": {
"get": {
- "description": "Get one lba job identified by it's id",
+ "description": "Get one lba job identified by it's id
+Limite : 5 appel(s) / 1 seconde(s)
+",
"operationId": "getLbaJob",
"parameters": [
{
@@ -5811,6 +5878,8 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/jobsEtFormations": {
"get": {
+ "description": "Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Une liste de codes ROME séparés par des virgules correspondant au(x) métier(s) recherché(s). Maximum 20.
rome et rncp sont incompatibles.
Au moins un des deux doit être renseigné.",
@@ -6158,7 +6227,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/metiers": {
"get": {
- "description": "Récupérer la liste des noms des métiers du référentiel de La bonne alternance",
+ "description": "Récupérer la liste des noms des métiers du référentiel de La bonne alternance
+Limite : 20 appel(s) / 1 seconde(s)
+",
"operationId": "getMetiers",
"parameters": [
{
@@ -6229,7 +6300,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/metiers/all": {
"get": {
- "description": "Retourne la liste de tous les métiers référencés sur LBA",
+ "description": "Retourne la liste de tous les métiers référencés sur LBA
+Limite : 20 appel(s) / 1 seconde(s)
+",
"operationId": "getTousLesMetiers",
"responses": {
"200": {
@@ -6270,7 +6343,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/metiers/intitule": {
"get": {
- "description": "Retourne une liste de métiers enrichis avec les codes romes associés correspondant aux critères en paramètres",
+ "description": "Retourne une liste de métiers enrichis avec les codes romes associés correspondant aux critères en paramètres
+Limite : 20 appel(s) / 1 seconde(s)
+",
"operationId": "getCoupleAppellationRomeIntitule",
"parameters": [
{
@@ -6337,7 +6412,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/metiers/metiersParFormation/{cfd}": {
"get": {
- "description": "Récupérer la liste des noms des métiers du référentiel de La bonne alternance pour une formation donnée",
+ "description": "Récupérer la liste des noms des métiers du référentiel de La bonne alternance pour une formation donnée
+Limite : 20 appel(s) / 1 seconde(s)
+",
"operationId": "getMetiersParCfd",
"parameters": [
{
@@ -6389,10 +6466,306 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
},
+ "/traininglinks": {
+ "post": {
+ "description": "Limite : 3 appel(s) / 1 seconde(s)
+",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "cfd": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ "cle_ministere_educatif": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ "code_insee": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ "code_postal": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ "id": {
+ "type": "string",
+ },
+ "mef": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ "rncp": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ "uai_formateur": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ "uai_formateur_responsable": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ "uai_lieu_formation": {
+ "type": [
+ "string",
+ "null",
+ ],
+ },
+ },
+ "required": [
+ "id",
+ ],
+ "type": "object",
+ },
+ "maxItems": 100,
+ "type": "array",
+ },
+ },
+ },
+ "required": true,
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ },
+ "lien_lba": {
+ "type": "string",
+ },
+ "lien_prdv": {
+ "type": "string",
+ },
+ },
+ "required": [
+ "id",
+ "lien_prdv",
+ "lien_lba",
+ ],
+ "type": "object",
+ },
+ "type": "array",
+ },
+ },
+ },
+ "description": "",
+ },
+ },
+ "security": [],
+ },
+ },
+ "/unsubscribe": {
+ "post": {
+ "description": "Limite : 1 appel(s) / 5 seconde(s)
+",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "email": {
+ "format": "email",
+ "type": "string",
+ },
+ "reason": {
+ "type": "string",
+ },
+ "sirets": {
+ "items": {
+ "description": "Le numéro de SIRET de l'établissement",
+ "example": "78424186100011",
+ "pattern": "^[0-9]{14}$",
+ "type": "string",
+ },
+ "type": [
+ "array",
+ "null",
+ ],
+ },
+ },
+ "required": [
+ "email",
+ "reason",
+ ],
+ "type": "object",
+ },
+ },
+ },
+ "required": true,
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "companies": {
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "address": {
+ "type": "string",
+ },
+ "enseigne": {
+ "type": "string",
+ },
+ "siret": {
+ "description": "Le numéro de SIRET de l'établissement",
+ "example": "78424186100011",
+ "pattern": "^[0-9]{14}$",
+ "type": "string",
+ },
+ },
+ "required": [
+ "enseigne",
+ "siret",
+ "address",
+ ],
+ "type": "object",
+ },
+ "type": [
+ "array",
+ "null",
+ ],
+ },
+ "result": {
+ "enum": [
+ "OK",
+ "NON_RECONNU",
+ "ETABLISSEMENTS_MULTIPLES",
+ ],
+ "type": "string",
+ },
+ },
+ "required": [
+ "result",
+ ],
+ "type": "object",
+ },
+ },
+ },
+ "description": "",
+ },
+ },
+ "security": [],
+ },
+ },
+ "/updateLBB/updateContactInfo": {
+ "get": {
+ "description": "Limite : 1 appel(s) / 20 seconde(s)
+",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "secret",
+ "required": true,
+ "schema": {
+ "type": "string",
+ },
+ },
+ {
+ "description": "Le numéro de SIRET de l'établissement",
+ "in": "query",
+ "name": "siret",
+ "required": true,
+ "schema": {
+ "description": "Le numéro de SIRET de l'établissement",
+ "example": "78424186100011",
+ "pattern": "^[0-9]{14}$",
+ "type": "string",
+ },
+ },
+ {
+ "in": "query",
+ "name": "email",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "format": "email",
+ "type": "string",
+ },
+ {
+ "enum": [
+ "",
+ ],
+ "type": "string",
+ },
+ ],
+ },
+ },
+ {
+ "in": "query",
+ "name": "phone",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ },
+ {
+ "enum": [
+ "",
+ ],
+ "type": "string",
+ },
+ ],
+ },
+ },
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "enum": [
+ "OK",
+ ],
+ "type": "string",
+ },
+ },
+ },
+ "description": "",
+ },
+ },
+ "security": [],
+ },
+ },
"/v1/application": {
"post": {
"description": "Envoi d'un email de candidature à une offre postée sur La bonne alternance recruteur ou une candidature spontanée à une entreprise identifiée par La bonne alternance.
L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne alternance.
+Limite : 5 appel(s) / 5 seconde(s)
",
"requestBody": {
"content": {
@@ -6481,7 +6854,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/formations": {
"get": {
- "description": "Rechercher des formations en alternance pour un métier ou un ensemble de métiers autour d'un point géographique",
+ "description": "Rechercher des formations en alternance pour un métier ou un ensemble de métiers autour d'un point géographique
+Limite : 7 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Une liste de codes ROME séparés par des virgules correspondant au(x) métier(s) recherché(s). Maximum 20.
rome et romeDomain sont incompatibles.
Au moins un des deux doit être renseigné.",
@@ -6662,7 +7037,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/formations/formation/{id}": {
"get": {
- "description": "Get one formation identified by it's clé ministère éducatif",
+ "description": "Get one formation identified by it's clé ministère éducatif
+Limite : 7 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "path",
@@ -6769,7 +7146,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/formations/min": {
"get": {
- "description": "Rechercher des formations en alternance pour un métier ou un ensemble de métiers autour d'un point géographique. Retour de données minimales",
+ "description": "Rechercher des formations en alternance pour un métier ou un ensemble de métiers autour d'un point géographique. Retour de données minimales
+Limite : 7 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Une liste de codes ROME séparés par des virgules correspondant au(x) métier(s) recherché(s). Maximum 20.
rome et romeDomain sont incompatibles.
Au moins un des deux doit être renseigné.",
@@ -6950,7 +7329,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/formationsParRegion": {
"get": {
- "description": "Rechercher des formations en alternance dans un département ou dans une région ou dans la France entière pour un métier ou un ensemble de métiers",
+ "description": "Rechercher des formations en alternance dans un département ou dans une région ou dans la France entière pour un métier ou un ensemble de métiers
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Une liste de codes ROME séparés par des virgules correspondant au(x) métier(s) recherché(s). Maximum 20.
rome et romeDomain sont incompatibles.
Au moins un des deux doit être renseigné dans le cas d'une recherche France entière.",
@@ -7111,7 +7492,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs": {
"get": {
- "description": "Get job opportunities matching the query parameters",
+ "description": "Get job opportunities matching the query parameters
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Une liste de codes ROME séparés par des virgules correspondant au(x) métier(s) recherché(s). Maximum 20.
rome et rncp sont incompatibles.
Au moins un des deux doit être renseigné.",
@@ -7393,7 +7776,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/bulk": {
"get": {
- "description": "Get all jobs related to my organization",
+ "description": "Get all jobs related to my organization
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "query mongodb query allowing specific filtering, JSON stringified",
@@ -7493,7 +7878,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/canceled/{jobId}": {
"post": {
- "description": "Update a job offer status to Canceled",
+ "description": "Update a job offer status to Canceled
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "path",
@@ -7530,7 +7917,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/company/{siret}": {
"get": {
- "description": "Get one company identified by it's siret",
+ "description": "Get one company identified by it's siret
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Le numéro de SIRET de l'établissement",
@@ -7656,7 +8045,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/delegations/{jobId}": {
"get": {
- "description": "Get related training organization related to a job offer.",
+ "description": "Get related training organization related to a job offer.
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "path",
@@ -7829,7 +8220,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/establishment": {
"get": {
- "description": "Get existing establishment id from siret & email",
+ "description": "Get existing establishment id from siret & email
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Le numéro de SIRET de l'établissement",
@@ -7895,7 +8288,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"post": {
- "description": "Create an establishment entity",
+ "description": "Create an establishment entity
+Limite : 5 appel(s) / 1 seconde(s)
+",
"requestBody": {
"content": {
"application/json": {
@@ -7984,7 +8379,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/extend/{jobId}": {
"post": {
- "description": "Update a job expiration date by 30 days.",
+ "description": "Update a job expiration date by 30 days.
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "path",
@@ -8021,7 +8418,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/job/{id}": {
"get": {
- "description": "Get one pe job identified by it's id",
+ "description": "Get one pe job identified by it's id
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "path",
@@ -8143,7 +8542,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/matcha/{id}": {
"get": {
- "description": "Get one lba job identified by it's id",
+ "description": "Get one lba job identified by it's id
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "the id the lba job looked for.",
@@ -8246,7 +8647,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/matcha/{id}/stats/view-details": {
"post": {
- "description": "Notifies that the detail of a matcha job has been viewed",
+ "description": "Notifies that the detail of a matcha job has been viewed
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "path",
@@ -8279,7 +8682,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/min": {
"get": {
- "description": "Get job opportunities matching the query parameters",
+ "description": "Get job opportunities matching the query parameters
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Une liste de codes ROME séparés par des virgules correspondant au(x) métier(s) recherché(s). Maximum 20.
rome et rncp sont incompatibles.
Au moins un des deux doit être renseigné.",
@@ -8561,7 +8966,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/provided/{jobId}": {
"post": {
- "description": "Update a job offer status to Provided",
+ "description": "Update a job offer status to Provided
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "path",
@@ -8598,7 +9005,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/{establishmentId}": {
"post": {
- "description": "Create a job offer inside an establishment entity.",
+ "description": "Create a job offer inside an establishment entity.
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "path",
@@ -8650,7 +9059,7 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"job_description": {
- "description": "Description de l'offre d'alternance",
+ "description": "Description de l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -8666,7 +9075,7 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"job_employer_description": {
- "description": "Description de l'employer proposant l'offre d'alternance",
+ "description": "Description de l'employer proposant l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -8770,7 +9179,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobs/{jobId}": {
"patch": {
- "description": "Update a job offer specific fields inside an establishment entity.",
+ "description": "Update a job offer specific fields inside an establishment entity.
+Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "path",
@@ -8818,7 +9229,7 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"job_description": {
- "description": "Description de l'offre d'alternance",
+ "description": "Description de l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -8834,7 +9245,7 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
"job_employer_description": {
- "description": "Description de l'employer proposant l'offre d'alternance",
+ "description": "Description de l'employer proposant l'offre d'alternance - minimum 30 charactères si rempli",
"type": [
"string",
"null",
@@ -8933,6 +9344,8 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/jobsEtFormations": {
"get": {
+ "description": "Limite : 5 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Une liste de codes ROME séparés par des virgules correspondant au(x) métier(s) recherché(s). Maximum 20.
rome et rncp sont incompatibles.
Au moins un des deux doit être renseigné.",
@@ -9276,7 +9689,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/metiers": {
"get": {
- "description": "Récupérer la liste des noms des métiers du référentiel de La bonne alternance",
+ "description": "Récupérer la liste des noms des métiers du référentiel de La bonne alternance
+Limite : 20 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "Un terme libre de recherche de métier.",
@@ -9342,7 +9757,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/metiers/all": {
"get": {
- "description": "Retourne la liste de tous les métiers référencés sur LBA",
+ "description": "Retourne la liste de tous les métiers référencés sur LBA
+Limite : 20 appel(s) / 1 seconde(s)
+",
"responses": {
"200": {
"content": {
@@ -9378,7 +9795,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/metiers/intitule": {
"get": {
- "description": "Retourne une liste de métiers enrichis avec les codes romes associés correspondant aux critères en paramètres",
+ "description": "Retourne une liste de métiers enrichis avec les codes romes associés correspondant aux critères en paramètres
+Limite : 20 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"in": "query",
@@ -9440,7 +9859,9 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
},
"/v1/metiers/metiersParFormation/{cfd}": {
"get": {
- "description": "Récupérer la liste des noms des métiers du référentiel de La bonne alternance pour une formation donnée",
+ "description": "Récupérer la liste des noms des métiers du référentiel de La bonne alternance pour une formation donnée
+Limite : 20 appel(s) / 1 seconde(s)
+",
"parameters": [
{
"description": "L'identifiant CFD de la formation.",
@@ -9487,6 +9908,34 @@ L'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne
],
},
},
+ "/version": {
+ "get": {
+ "description": "Limite : 3 appel(s) / 1 seconde(s)
+",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "type": "string",
+ },
+ },
+ "required": [
+ "version",
+ ],
+ "type": "object",
+ },
+ },
+ },
+ "description": "",
+ },
+ },
+ "security": [],
+ },
+ },
},
"servers": [
{
diff --git a/shared/models/address.model.ts b/shared/models/address.model.ts
index ba1ebf5525..fbd1fc1d84 100644
--- a/shared/models/address.model.ts
+++ b/shared/models/address.model.ts
@@ -68,21 +68,22 @@ export const ZAdresseCFA = z
.strict()
.openapi("AdresseCFA")
-const ZAdresseV2 = ZAcheminementPostal.extend({
- numero_voie: z.string(),
- type_voie: z.string(),
- nom_voie: z.string(),
- complement_adresse: z.string(),
- code_postal: z.string(),
- localite: z.string(),
- code_insee_localite: z.string(),
- cedex: z.null(),
- acheminement_postal: ZAcheminementPostal.optional(),
-})
+const ZAdresseV2 = z
+ .object({
+ numero_voie: z.string().nullish(),
+ type_voie: z.string().nullish(),
+ nom_voie: z.string().nullish(),
+ complement_adresse: z.string().nullish(),
+ code_postal: z.string().nullish(),
+ localite: z.string().nullish(),
+ code_insee_localite: z.string().nullish(),
+ cedex: z.string().nullish(),
+ acheminement_postal: ZAcheminementPostal.optional(),
+ })
.strict()
.openapi("AdresseV2")
-const ZAdresseV3 = z
+export const ZAdresseV3 = z
.object({
status_diffusion: z.string().nullish(),
complement_adresse: z.string().nullish(),
diff --git a/shared/models/appointments.model.ts b/shared/models/appointments.model.ts
index 9befab0cd9..dd832c9964 100644
--- a/shared/models/appointments.model.ts
+++ b/shared/models/appointments.model.ts
@@ -1,8 +1,10 @@
import { Jsonify } from "type-fest"
+import { AppointmentUserType } from "../constants/appointment"
import { z } from "../helpers/zodWithOpenApi"
import { zObjectId } from "./common"
+import { enumToZod } from "./enumToZod"
export const enum EReasonsKey {
MODALITE = "modalite",
@@ -32,7 +34,7 @@ export const ZMailing = z
export const ZAppointment = z
.object({
_id: zObjectId,
- applicant_id: z.string(),
+ applicant_id: zObjectId,
cfa_intention_to_applicant: z.string().nullish(),
cfa_message_to_applicant_date: z.date().nullish(),
cfa_message_to_applicant: z.string().nullish(),
@@ -47,6 +49,7 @@ export const ZAppointment = z
cle_ministere_educatif: z.string(),
created_at: z.date().default(() => new Date()),
cfa_recipient_email: z.string(),
+ applicant_type: enumToZod(AppointmentUserType).nullish(),
})
.strict()
.openapi("Appointment")
diff --git a/shared/models/cfa.model.ts b/shared/models/cfa.model.ts
new file mode 100644
index 0000000000..6f487f62bc
--- /dev/null
+++ b/shared/models/cfa.model.ts
@@ -0,0 +1,24 @@
+import { Jsonify } from "type-fest"
+
+import { z } from "../helpers/zodWithOpenApi"
+
+import { ZGlobalAddress } from "./address.model"
+import { zObjectId } from "./common"
+
+export const zCFA = z
+ .object({
+ _id: zObjectId,
+ origin: z.string().nullish(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ siret: z.string(),
+ raison_sociale: z.string().nullish(),
+ enseigne: z.string().nullish(),
+ address_detail: ZGlobalAddress.nullish(),
+ address: z.string().nullish(),
+ geo_coordinates: z.string().nullish(),
+ })
+ .strict()
+
+export type ICFA = z.output
+export type ICFAJson = Jsonify>
diff --git a/shared/models/computedUserAccess.model.ts b/shared/models/computedUserAccess.model.ts
new file mode 100644
index 0000000000..7aac77da53
--- /dev/null
+++ b/shared/models/computedUserAccess.model.ts
@@ -0,0 +1,16 @@
+import { OPCOS } from "../constants/recruteur"
+import { z } from "../helpers/zodWithOpenApi"
+
+import { enumToZod } from "./enumToZod"
+
+export const ZComputedUserAccess = z
+ .object({
+ admin: z.boolean(),
+ users: z.array(z.string()),
+ entreprises: z.array(z.string()),
+ cfas: z.array(z.string()),
+ opcos: z.array(enumToZod(OPCOS)),
+ })
+ .strict()
+
+export type ComputedUserAccess = z.output
diff --git a/shared/models/entreprise.model.ts b/shared/models/entreprise.model.ts
new file mode 100644
index 0000000000..81afe3d1cd
--- /dev/null
+++ b/shared/models/entreprise.model.ts
@@ -0,0 +1,48 @@
+import { Jsonify } from "type-fest"
+
+import { z } from "../helpers/zodWithOpenApi"
+
+import { ZGlobalAddress } from "./address.model"
+import { zObjectId } from "./common"
+import { enumToZod } from "./enumToZod"
+import { ZValidationUtilisateur } from "./user2.model"
+
+export enum EntrepriseStatus {
+ ERROR = "ERROR",
+ VALIDE = "VALIDE",
+ DESACTIVE = "DESACTIVE",
+ A_METTRE_A_JOUR = "A_METTRE_A_JOUR",
+}
+
+export const ZEntrepriseStatusEvent = z
+ .object({
+ validation_type: ZValidationUtilisateur.describe("Indique si l'action est ordonnée par un utilisateur ou le serveur"),
+ status: enumToZod(EntrepriseStatus).describe("Statut de l'accès"),
+ reason: z.string().describe("Raison du changement de statut"),
+ date: z.date().describe("Date de l'évènement"),
+ granted_by: z.string().nullish().describe("Utilisateur à l'origine du changement"),
+ })
+ .strict()
+
+export type IEntrepriseStatusEvent = z.output
+
+export const ZEntreprise = z
+ .object({
+ _id: zObjectId,
+ origin: z.string().nullish().describe("Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi"),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ siret: z.string().describe("Siret de l'établissement"),
+ raison_sociale: z.string().nullish().describe("Raison sociale de l'établissement"),
+ enseigne: z.string().nullish().describe("Enseigne de l'établissement"),
+ idcc: z.string().nullish().describe("Identifiant convention collective de l'entreprise"),
+ address: z.string().nullish().describe("Adresse de l'établissement"),
+ address_detail: ZGlobalAddress.nullish().describe("Detail de l'adresse de l'établissement"),
+ geo_coordinates: z.string().nullish().describe("Latitude/Longitude de l'adresse de l'entreprise"),
+ opco: z.string().nullish().describe("Opco de l'entreprise"),
+ status: z.array(ZEntrepriseStatusEvent).describe("historique de la mise à jour des données entreprise"),
+ })
+ .strict()
+
+export type IEntreprise = z.output
+export type IEntrepriseJson = Jsonify>
diff --git a/shared/models/enumToZod.ts b/shared/models/enumToZod.ts
new file mode 100644
index 0000000000..74bb2ecca0
--- /dev/null
+++ b/shared/models/enumToZod.ts
@@ -0,0 +1,6 @@
+import { z } from "../helpers/zodWithOpenApi"
+
+export function enumToZod(enumObject: Record): z.ZodEnum<[Value, ...Value[]]> {
+ const enumValues = Object.values(enumObject)
+ return z.enum([enumValues[0], ...enumValues.slice(1)])
+}
diff --git a/shared/models/index.ts b/shared/models/index.ts
index d70fbed411..86861c236c 100644
--- a/shared/models/index.ts
+++ b/shared/models/index.ts
@@ -27,3 +27,4 @@ export * from "./unsubscribeOF.model"
export * from "./unsubscribedLbaCompany.model"
export * from "./user.model"
export * from "./usersRecruteur.model"
+export * from "./computedUserAccess.model"
diff --git a/shared/models/job.model.ts b/shared/models/job.model.ts
index 55115e28e9..b9d1249a75 100644
--- a/shared/models/job.model.ts
+++ b/shared/models/job.model.ts
@@ -38,8 +38,8 @@ export const ZJobFields = z
.nullish()
.describe("Niveau de formation visé en fin de stage"),
job_start_date: z.coerce.date().describe("Date de début de l'alternance"),
- job_description: z.string().nullish().describe("Description de l'offre d'alternance"),
- job_employer_description: z.string().nullish().describe("Description de l'employer proposant l'offre d'alternance"),
+ job_description: z.string().nullish().describe("Description de l'offre d'alternance - minimum 30 charactères si rempli"),
+ job_employer_description: z.string().nullish().describe("Description de l'employer proposant l'offre d'alternance - minimum 30 charactères si rempli"),
rome_code: z.array(z.string()).describe("Liste des romes liés au métier"),
rome_detail: ZRomeDetail.nullish().describe("Détail du code ROME selon la nomenclature Pole emploi"),
job_creation_date: z.date().nullish().describe("Date de creation de l'offre"),
@@ -65,6 +65,7 @@ export const ZJobFields = z
custom_geo_coordinates: z.string().nullish().describe("Latitude/Longitude de l'adresse personnalisée de l'entreprise"),
stats_detail_view: z.number().nullish().describe("Nombre de vues de la page de détail"),
stats_search_view: z.number().nullish().describe("Nombre de vues sur une page de recherche"),
+ managed_by: zObjectId.nullish().describe("Id de l'utilisateur gérant l'offre"),
})
.strict()
.openapi("JobWritable")
diff --git a/shared/models/lbaItem.model.ts b/shared/models/lbaItem.model.ts
index 710367a282..ce9aee29cb 100644
--- a/shared/models/lbaItem.model.ts
+++ b/shared/models/lbaItem.model.ts
@@ -294,6 +294,7 @@ export const ZLbaItemFormation = z
example: "5e8dfad720ff3b2161269d86",
description: "L'identifiant de la formation dans le catalogue du Réseau des Carif-Oref.",
}), // formation -> id
+ detailsLoaded: z.boolean().nullish(),
idRco: z.string().nullish(), // formation -> id_formation
idRcoFormation: z.string().nullish(), // formation -> id_rco_formation
@@ -374,7 +375,6 @@ export const ZLbaItemLbaJob = z
contact: ZLbaItemContact.nullish(),
place: ZLbaItemPlace.nullable(),
company: ZLbaItemCompany.nullable(),
-
id: z.string().nullable().openapi({}), // matcha -> id_form
diplomaLevel: z
.string()
@@ -387,6 +387,7 @@ export const ZLbaItemLbaJob = z
romes: z.array(ZLbaItemRome).nullish(),
nafs: z.array(ZLbaItemNaf).nullish(),
applicationCount: z.number(), // calcul en fonction du nombre de candidatures enregistrées
+ detailsLoaded: z.boolean().nullish(),
})
.strict()
.openapi("LbaJob")
@@ -405,6 +406,7 @@ export const ZLbaItemLbaCompany = z
url: z.string().nullish(),
nafs: z.array(ZLbaItemNaf).nullish(),
applicationCount: z.number(), // calcul en fonction du nombre de candidatures enregistrées
+ detailsLoaded: z.boolean().nullish(),
})
.strict()
.openapi("LbaCompany")
@@ -424,6 +426,7 @@ export const ZLbaItemFtJob = z
job: ZLbaItemJob.nullish(),
romes: z.array(ZLbaItemRome).nullish(),
nafs: z.array(ZLbaItemNaf).nullish(),
+ detailsLoaded: z.boolean().nullish(),
})
.strict()
.openapi("PeJob")
diff --git a/shared/models/recruiter.model.ts b/shared/models/recruiter.model.ts
index 8859403431..44b8c570d5 100644
--- a/shared/models/recruiter.model.ts
+++ b/shared/models/recruiter.model.ts
@@ -41,6 +41,7 @@ export const ZRecruiterWritable = z
naf_label: z.string().nullish().describe("Libellé NAF de l'établissement"),
establishment_size: z.string().nullish().describe("Tranche d'effectif salariale de l'établissement"),
establishment_creation_date: z.date().nullish().describe("Date de creation de l'établissement"),
+ managed_by: zObjectId.nullish().describe("Id de l'utilisateur gestionnaire"),
})
.strict()
.openapi("RecruiterWritable")
diff --git a/shared/models/roleManagement.model.ts b/shared/models/roleManagement.model.ts
new file mode 100644
index 0000000000..13befee126
--- /dev/null
+++ b/shared/models/roleManagement.model.ts
@@ -0,0 +1,50 @@
+import { Jsonify } from "type-fest"
+
+import { z } from "../helpers/zodWithOpenApi"
+
+import { zObjectId } from "./common"
+import { enumToZod } from "./enumToZod"
+import { ZValidationUtilisateur } from "./user2.model"
+
+export enum AccessEntityType {
+ USER = "USER",
+ ENTREPRISE = "ENTREPRISE",
+ CFA = "CFA",
+ OPCO = "OPCO",
+ ADMIN = "ADMIN",
+}
+
+export enum AccessStatus {
+ GRANTED = "GRANTED",
+ DENIED = "DENIED",
+ AWAITING_VALIDATION = "AWAITING_VALIDATION",
+}
+
+export const ZRoleManagementEvent = z
+ .object({
+ validation_type: ZValidationUtilisateur.describe("Indique si l'action est ordonnée par un utilisateur ou le serveur"),
+ status: enumToZod(AccessStatus).describe("Statut de l'accès"),
+ reason: z.string().describe("Raison du changement de statut"),
+ date: z.date().describe("Date de l'évènement"),
+ granted_by: z.string().nullish().describe("Utilisateur à l'origine du changement"),
+ })
+ .strict()
+
+export const ZAccessEntityType = enumToZod(AccessEntityType)
+
+export const ZRoleManagement = z
+ .object({
+ _id: zObjectId,
+ origin: z.string().describe("Origine de la creation"),
+ status: z.array(ZRoleManagementEvent).describe("Evénements liés au cycle de vie de l'accès"),
+ authorized_id: z.string().describe("ID de l'entité sur laquelle l'accès est exercé"),
+ authorized_type: ZAccessEntityType.describe("Type de l'entité sur laquelle l'accès est exercé"),
+ user_id: zObjectId.describe("ID de l'utilisateur ayant accès"),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ })
+ .strict()
+
+export type IRoleManagement = z.output
+export type IRoleManagementJson = Jsonify>
+export type IRoleManagementEvent = z.output
diff --git a/shared/models/user2.model.ts b/shared/models/user2.model.ts
new file mode 100644
index 0000000000..c10f244f31
--- /dev/null
+++ b/shared/models/user2.model.ts
@@ -0,0 +1,47 @@
+import { Jsonify } from "type-fest"
+
+import { VALIDATION_UTILISATEUR } from "../constants/recruteur"
+import { extensions } from "../helpers/zodHelpers/zodPrimitives"
+import { z } from "../helpers/zodWithOpenApi"
+
+import { zObjectId } from "./common"
+import { enumToZod } from "./enumToZod"
+
+export enum UserEventType {
+ ACTIF = "ACTIF",
+ VALIDATION_EMAIL = "VALIDATION_EMAIL",
+ DESACTIVE = "DESACTIVE",
+}
+
+export const ZValidationUtilisateur = enumToZod(VALIDATION_UTILISATEUR)
+
+export const ZUserStatusEvent = z
+ .object({
+ validation_type: ZValidationUtilisateur,
+ status: enumToZod(UserEventType),
+ reason: z.string(),
+ granted_by: z.string().nullish(),
+ date: z.date(),
+ })
+ .strict()
+
+export const ZUser2 = z
+ .object({
+ _id: zObjectId,
+ origin: z.string().nullish(),
+ status: z.array(ZUserStatusEvent),
+ first_name: z.string(),
+ last_name: z.string(),
+ email: z.string().email(),
+ phone: extensions.phone(),
+ last_action_date: z.date().nullish(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ })
+ .strict()
+
+export type IUser2 = z.output
+export type IUser2Json = Jsonify>
+
+export type IUserStatusEvent = z.output
+export type IUserStatusEventJson = Jsonify>
diff --git a/shared/models/usersRecruteur.model.ts b/shared/models/usersRecruteur.model.ts
index 4279c083f9..55614715e6 100644
--- a/shared/models/usersRecruteur.model.ts
+++ b/shared/models/usersRecruteur.model.ts
@@ -7,15 +7,16 @@ import { z } from "../helpers/zodWithOpenApi"
import { ZGlobalAddress, ZPointGeometry } from "./address.model"
import { zObjectId } from "./common"
+import { enumToZod } from "./enumToZod"
+import { IUser2, ZValidationUtilisateur } from "./user2.model"
-const etatUtilisateurValues = Object.values(ETAT_UTILISATEUR)
-export const ZEtatUtilisateur = z.enum([etatUtilisateurValues[0], ...etatUtilisateurValues.slice(1)]).describe("Statut de l'utilisateur")
+export const ZEtatUtilisateur = enumToZod(ETAT_UTILISATEUR).describe("Statut de l'utilisateur")
const authTypeValues = Object.values(AUTHTYPE)
export const ZUserStatusValidation = z
.object({
- validation_type: z.enum(["AUTOMATIQUE", "MANUELLE"]).describe("Processus de validation lors de l'inscription de l'utilisateur"),
+ validation_type: ZValidationUtilisateur.describe("Processus de validation lors de l'inscription de l'utilisateur"),
// TODO : check DB and remove nullish
status: ZEtatUtilisateur.nullish(),
reason: z.string().nullish().describe("Raison du changement de statut"),
@@ -111,15 +112,12 @@ export const ZUserRecruteurPublic = ZUserRecruteur.pick({
last_name: true,
first_name: true,
phone: true,
- opco: true,
- idcc: true,
- scope: true,
- establishment_siret: true,
establishment_id: true,
+ establishment_siret: true,
+ scope: true,
}).extend({
- is_delegated: z.boolean(),
cfa_delegated_siret: extensions.siret.nullish(),
- status_current: z.enum([etatUtilisateurValues[0], ...etatUtilisateurValues.slice(1)]).nullish(),
+ status_current: enumToZod(ETAT_UTILISATEUR).nullish(),
})
export type IUserRecruteurPublic = Jsonify