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

- femme ne savant pas quoi faire + femme ne sachant pas quoi faire
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> @@ -137,22 +135,20 @@ export const getUserStatus = (stateArray: IUserRecruteur["status"]) => { return lastValidationEvent.status } -export function toPublicUser(user: IUserRecruteur): z.output { +export function toPublicUser( + user: IUser2, + userRecruteurProps: Pick +): z.output { + const { type, establishment_siret } = userRecruteurProps + const cfa_delegated_siret = type === CFA ? establishment_siret : undefined return { + ...userRecruteurProps, _id: user._id, email: user.email, - type: user.type, - last_name: user.last_name, - first_name: user.first_name, - phone: user.phone, - opco: user.opco, - idcc: user.idcc, - scope: user.scope, - establishment_siret: user.establishment_siret, - establishment_id: user.establishment_id, - is_delegated: user.type === CFA ? true : false, - cfa_delegated_siret: user.type === CFA ? user.establishment_siret : undefined, - status_current: getUserStatus(user.status), + last_name: user.last_name ?? "", + first_name: user.first_name ?? "", + phone: user.phone ?? "", + cfa_delegated_siret, } } @@ -179,7 +175,6 @@ export type IAnonymizedUserRecruteur = z.output export const UserRecruteurForAdminProjection = { _id: true, - establishment_id: true, establishment_raison_sociale: true, establishment_siret: true, type: true, @@ -193,4 +188,8 @@ export const UserRecruteurForAdminProjection = { status: true, } as const -export const ZUserRecruteurForAdmin = ZUserRecruteur.pick(UserRecruteurForAdminProjection) +export const ZUserRecruteurForAdmin = ZUserRecruteur.pick(UserRecruteurForAdminProjection).extend({ + organizationId: zObjectId, +}) + +export type IUserRecruteurForAdmin = z.output diff --git a/shared/routes/application.routes.ts b/shared/routes/application.routes.ts index c8dab16fda..868845dc35 100644 --- a/shared/routes/application.routes.ts +++ b/shared/routes/application.routes.ts @@ -2,6 +2,7 @@ import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" import { ZLbacError } from "../models" import { ZNewApplication } from "../models/applications.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { IRoutesDef, ZResError } from "./common.routes" @@ -31,8 +32,9 @@ export const zApplicationRoutes = { securityScheme: null, openapi: { tags: ["V1 - Applications"] as string[], - 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.\nL'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne alternance.\n", + 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.\nL'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne alternance.\n${rateLimitDescription( + { max: 5, timeWindow: "5s" } + )}`, }, }, "/application/intention/:id": { diff --git a/shared/routes/application.routes.v2.ts b/shared/routes/application.routes.v2.ts index 0d7a0ad25d..dc070acec3 100644 --- a/shared/routes/application.routes.v2.ts +++ b/shared/routes/application.routes.v2.ts @@ -1,6 +1,7 @@ import { z } from "../helpers/zodWithOpenApi" import { ZLbacError } from "../models" import { ZNewApplicationV2 } from "../models/applications.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { IRoutesDef, ZResError } from "./common.routes" @@ -30,8 +31,9 @@ export const zApplicationRoutesV2 = { securityScheme: { auth: "api-key", access: null, resources: {} }, openapi: { tags: ["V2 - Applications"] as string[], - 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.\nL'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne alternance.\n", + 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.\nL'email est envoyé depuis l'adresse générique 'Ne pas répondre' de La bonne alternance.\n${rateLimitDescription( + { max: 5, timeWindow: "5s" } + )}`, }, }, }, diff --git a/shared/routes/core.routes.ts b/shared/routes/core.routes.ts index 2ef7a81809..f44704838e 100644 --- a/shared/routes/core.routes.ts +++ b/shared/routes/core.routes.ts @@ -1,4 +1,5 @@ import { z } from "../helpers/zodWithOpenApi" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { IRoutesDef, ZResError } from "./common.routes" @@ -44,6 +45,9 @@ export const zCoreRoutes = { .strict(), }, securityScheme: null, + openapi: { + description: `${rateLimitDescription({ max: 3, timeWindow: "1s" })}`, + }, }, }, } as const satisfies IRoutesDef diff --git a/shared/routes/formations.routes.v2.ts b/shared/routes/formations.routes.v2.ts index 1fb730ffb2..d273966c82 100644 --- a/shared/routes/formations.routes.v2.ts +++ b/shared/routes/formations.routes.v2.ts @@ -1,6 +1,7 @@ import { z } from "../helpers/zodWithOpenApi" import { ZApiError, ZLbacError } from "../models/lbacError.model" import { ZLbaItemFormation, ZLbaItemFormationResult, ZLbaItemLbaCompany, ZLbaItemLbaJob, ZLbaItemFtJob } from "../models/lbaItem.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { zCallerParam, @@ -64,7 +65,10 @@ export const zFormationsRoutesV2 = { openapi: { tags: ["V2 - Formations"] as string[], operationId: "getFormations", - 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\n${rateLimitDescription({ + max: 7, + timeWindow: "1s", + })}`, }, }, "/formations/min": { @@ -110,7 +114,9 @@ export const zFormationsRoutesV2 = { openapi: { tags: ["V2 - Formations"] as string[], operationId: "getFormations", - 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.\n${rateLimitDescription( + { max: 7, timeWindow: "1s" } + )}`, }, }, "/formations/formation/:id": { @@ -140,7 +146,7 @@ export const zFormationsRoutesV2 = { openapi: { tags: ["V2 - Formations"] as string[], operationId: "getFormation", - description: "Get one formation identified by it's clé ministère éducatif", + description: `Get one formation identified by it's clé ministère éducatif\n${rateLimitDescription({ max: 7, timeWindow: "1s" })}`, }, }, "/formationsParRegion": { @@ -207,7 +213,9 @@ export const zFormationsRoutesV2 = { }, openapi: { tags: ["V2 - Formations"] as string[], - 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\n${rateLimitDescription( + { max: 7, timeWindow: "1s" } + )}`, operationId: "getFormations", }, }, @@ -286,6 +294,7 @@ export const zFormationsRoutesV2 = { securityScheme: { auth: "api-key", access: null, resources: {} }, openapi: { tags: ["V2 - Jobs et formations"] as string[], + description: `${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, }, diff --git a/shared/routes/index.ts b/shared/routes/index.ts index 0b44337008..067fa61b32 100644 --- a/shared/routes/index.ts +++ b/shared/routes/index.ts @@ -42,9 +42,6 @@ const zRoutesGetP1 = { const zRoutesGetP2 = { ...zV1JobsRoutes.get, ...zV1FormationsRoutes.get, - ...zV1JobsEtFormationsRoutes.get, - ...zFormulaireRoute.get, - ...zRecruiterRoutes.get, } as const const zRoutesGetP3 = { @@ -65,11 +62,18 @@ const zRoutesGetP4 = { ...zLoginRoutes.get, } as const -const zRoutesGet: typeof zRoutesGetP1 & typeof zRoutesGetP2 & typeof zRoutesGetP3 & typeof zRoutesGetP4 = { +const zRoutesGetP5 = { + ...zV1JobsEtFormationsRoutes.get, + ...zFormulaireRoute.get, + ...zRecruiterRoutes.get, +} + +const zRoutesGet: typeof zRoutesGetP1 & typeof zRoutesGetP2 & typeof zRoutesGetP3 & typeof zRoutesGetP4 & typeof zRoutesGetP5 = { ...zRoutesGetP1, ...zRoutesGetP2, ...zRoutesGetP3, ...zRoutesGetP4, + ...zRoutesGetP5, } as const const zRoutesPost1 = { diff --git a/shared/routes/jobs.routes.v2.ts b/shared/routes/jobs.routes.v2.ts index c7cf4cd50d..69f340ca3e 100644 --- a/shared/routes/jobs.routes.v2.ts +++ b/shared/routes/jobs.routes.v2.ts @@ -5,6 +5,7 @@ import { zObjectId } from "../models/common" import { ZApiError, ZLbacError, ZLbarError } from "../models/lbacError.model" import { ZLbaItemFtJob, ZLbaItemLbaCompany, ZLbaItemLbaJob } from "../models/lbaItem.model" import { ZRecruiter } from "../models/recruiter.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { zCallerParam, @@ -55,7 +56,7 @@ export const zJobsRoutesV2 = { }, openapi: { tags: ["V2 - Jobs"] as string[], - description: "Get existing establishment id from siret & email", + description: `Get existing establishment id from siret & email\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/jobs/bulk": { @@ -120,7 +121,7 @@ export const zJobsRoutesV2 = { }, openapi: { tags: ["V2 - Jobs"] as string[], - description: "Get all jobs related to my organization", + description: `Get all jobs related to my organization\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, operationId: "getJobs", }, }, @@ -158,7 +159,7 @@ export const zJobsRoutesV2 = { openapi: { tags: ["V2 - Jobs"] as string[], operationId: "getDelegation", - description: "Get related training organization related to a job offer.", + description: `Get related training organization related to a job offer.\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/jobs": { @@ -225,7 +226,7 @@ export const zJobsRoutesV2 = { openapi: { tags: ["V2 - Jobs"] as string[], operationId: "getJobOpportunities", - description: "Get job opportunities matching the query parameters", + description: `Get job opportunities matching the query parameters\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/jobs/min": { @@ -291,7 +292,7 @@ export const zJobsRoutesV2 = { openapi: { tags: ["V2 - Jobs - Min"] as string[], operationId: "getJobOpportunities", - description: "Get job opportunities matching the query parameters and returns minimal data", + description: `Get job opportunities matching the query parameters and returns minimal data\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/jobs/entreprise_lba/:siret": { @@ -327,7 +328,7 @@ export const zJobsRoutesV2 = { openapi: { tags: ["V2 - Jobs"] as string[], operationId: "getCompany", - description: "Get one company identified by it's siret", + description: `Get one company identified by it's siret\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/jobs/:source/:id": { @@ -368,7 +369,7 @@ export const zJobsRoutesV2 = { openapi: { tags: ["V2 - Jobs"] as string[], operationId: "getLbaJob", - description: "Get one lba job identified by it's id", + description: `Get one lba job identified by it's id\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, }, @@ -406,7 +407,7 @@ export const zJobsRoutesV2 = { }, openapi: { tags: ["V2 - Jobs"] as string[], - description: "Create an establishment entity", + description: `Create an establishment entity\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, operationId: "createEstablishment", }, }, @@ -428,7 +429,7 @@ export const zJobsRoutesV2 = { }) .extend({ job_start_date: ZJobStartDateCreate(), - appellation_code: z.string().regex(/^[0-9]+$/, "appelation code must contains only numbers"), + appellation_code: z.string().regex(/^[0-9]+$/, "appelation code ne doit contenir que des chiffres"), }) .strict() .refine( @@ -438,7 +439,25 @@ export const zJobsRoutesV2 = { } return true }, - { message: "custom_geo_coordinates must be filled if a custom_address is passed" } + { message: "custom_geo_coordinates est obligatoire si custom_address est passé en paramètre" } + ) + .refine( + ({ job_description }) => { + if (job_description && job_description?.length < 30) { + return false + } + return true + }, + { message: "job_description doit avoir un minimum de 30 caractères" } + ) + .refine( + ({ job_employer_description }) => { + if (job_employer_description && job_employer_description?.length < 30) { + return false + } + return true + }, + { message: "job_employer_description doit avoir un minimum de 30 caractères" } ), response: { "201": ZRecruiter, @@ -457,7 +476,7 @@ export const zJobsRoutesV2 = { }, openapi: { tags: ["V2 - Jobs"] as string[], - description: "Create a job offer inside an establishment entity.", + description: `Create a job offer inside an establishment entity.\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, operationId: "createJob", }, }, @@ -511,7 +530,7 @@ export const zJobsRoutesV2 = { }, openapi: { tags: ["V2 - Jobs"] as string[], - description: "Update a job offer status to Provided", + description: `Update a job offer status to Provided\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, operationId: "setJobAsProvided", }, }, @@ -536,7 +555,7 @@ export const zJobsRoutesV2 = { openapi: { tags: ["V2 - Jobs"] as string[], operationId: "setJobAsCanceled", - description: "Update a job offer status to Canceled", + description: `Update a job offer status to Canceled\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/jobs/extend/:jobId": { @@ -560,7 +579,7 @@ export const zJobsRoutesV2 = { openapi: { tags: ["V2 - Jobs"] as string[], operationId: "extendJobExpiration", - description: "Update a job expiration date by 30 days.", + description: `Update a job expiration date by 30 days.\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/jobs/matcha/:id/stats/view-details": { @@ -578,7 +597,7 @@ export const zJobsRoutesV2 = { openapi: { tags: ["V2 - Jobs"] as string[], operationId: "statsViewLbaJob", - description: "Notifies that the detail of a matcha job has been viewed", + description: `Notifies that the detail of a matcha job has been viewed\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, }, @@ -621,7 +640,7 @@ export const zJobsRoutesV2 = { openapi: { tags: ["V2 - Jobs"] as string[], operationId: "updateJob", - description: "Update a job offer specific fields inside an establishment entity.", + description: `Update a job offer specific fields inside an establishment entity.\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, }, diff --git a/shared/routes/login.routes.ts b/shared/routes/login.routes.ts index 09859e7f94..75278914c1 100644 --- a/shared/routes/login.routes.ts +++ b/shared/routes/login.routes.ts @@ -1,5 +1,5 @@ import { z } from "../helpers/zodWithOpenApi" -import { ZUserRecruteurPublic } from "../models" +import { ZComputedUserAccess, ZUserRecruteurPublic } from "../models" import { zObjectId } from "../models/common" import { IRoutesDef } from "./common.routes" @@ -18,11 +18,9 @@ export const zLoginRoutes = { "200": z.object({}).strict(), }, securityScheme: { - auth: "cookie-session", - access: "user:manage", - resources: { - user: [{ _id: { key: "userId", type: "params" } }], - }, + auth: "access-token", + access: null, + resources: {}, }, }, "/login/magiclink": { @@ -65,6 +63,18 @@ export const zLoginRoutes = { resources: {}, }, }, + "/auth/access": { + method: "get", + path: "/auth/access", + response: { + "200": ZComputedUserAccess, + }, + securityScheme: { + auth: "cookie-session", + access: null, + resources: {}, + }, + }, "/auth/logout": { method: "get", path: "/auth/logout", diff --git a/shared/routes/metiers.routes.ts b/shared/routes/metiers.routes.ts index 8f3cb5f984..b983f18c09 100644 --- a/shared/routes/metiers.routes.ts +++ b/shared/routes/metiers.routes.ts @@ -1,5 +1,6 @@ import { z } from "../helpers/zodWithOpenApi" import { ZAppellationsRomes, ZMetierEnrichiArray, ZMetiers } from "../models/metiers.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { IRoutesDef } from "./common.routes" @@ -26,7 +27,10 @@ export const zMetiersRoutes = { }, securityScheme: null, openapi: { - 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\n${rateLimitDescription({ + max: 20, + timeWindow: "1s", + })}`, tags: ["V1 - Metiers"] as string[], }, }, @@ -38,7 +42,7 @@ export const zMetiersRoutes = { }, securityScheme: null, openapi: { - 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\n${rateLimitDescription({ max: 20, timeWindow: "1s" })}`, tags: ["V1 - Metiers"] as string[], }, }, @@ -82,7 +86,7 @@ export const zMetiersRoutes = { }, securityScheme: null, openapi: { - 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\n${rateLimitDescription({ max: 20, timeWindow: "1s" })}`, tags: ["V1 - Metiers"] as string[], }, }, @@ -101,7 +105,10 @@ export const zMetiersRoutes = { }, securityScheme: null, openapi: { - 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\n${rateLimitDescription({ + max: 20, + timeWindow: "1s", + })}`, tags: ["V1 - Metiers"] as string[], }, }, diff --git a/shared/routes/metiers.routes.v2.ts b/shared/routes/metiers.routes.v2.ts index c0a06b9144..89e74c7283 100644 --- a/shared/routes/metiers.routes.v2.ts +++ b/shared/routes/metiers.routes.v2.ts @@ -1,5 +1,6 @@ import { z } from "../helpers/zodWithOpenApi" import { ZAppellationsRomes, ZMetierEnrichiArray, ZMetiers } from "../models/metiers.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { IRoutesDef } from "./common.routes" @@ -26,7 +27,10 @@ export const zMetiersRoutesV2 = { }, securityScheme: { auth: "api-key", access: null, resources: {} }, openapi: { - 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\n${rateLimitDescription({ + max: 20, + timeWindow: "1s", + })}`, tags: ["V2 - Metiers"] as string[], operationId: "getMetiersParCfd", }, @@ -39,7 +43,7 @@ export const zMetiersRoutesV2 = { }, securityScheme: { auth: "api-key", access: null, resources: {} }, openapi: { - 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\n${rateLimitDescription({ max: 20, timeWindow: "1s" })}`, tags: ["V2 - Metiers"] as string[], operationId: "getTousLesMetiers", }, @@ -84,7 +88,7 @@ export const zMetiersRoutesV2 = { }, securityScheme: { auth: "api-key", access: null, resources: {} }, openapi: { - 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\n${rateLimitDescription({ max: 20, timeWindow: "1s" })}`, tags: ["V2 - Metiers"] as string[], operationId: "getMetiers", }, @@ -104,7 +108,10 @@ export const zMetiersRoutesV2 = { }, securityScheme: { auth: "api-key", access: null, resources: {} }, openapi: { - 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\n${rateLimitDescription({ + max: 20, + timeWindow: "1s", + })}`, tags: ["V2 - Metiers"] as string[], operationId: "getCoupleAppellationRomeIntitule", }, diff --git a/shared/routes/recruiters.routes.ts b/shared/routes/recruiters.routes.ts index 294e55b3b3..84ed12f276 100644 --- a/shared/routes/recruiters.routes.ts +++ b/shared/routes/recruiters.routes.ts @@ -4,7 +4,8 @@ import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" import { ZPointGeometry, ZRecruiter } from "../models" import { zObjectId } from "../models/common" -import { ZCfaReferentielData, ZUserRecruteur, ZUserRecruteurPublic, ZUserRecruteurWritable } from "../models/usersRecruteur.model" +import { ZUser2 } from "../models/user2.model" +import { ZCfaReferentielData, ZUserRecruteurPublic, ZUserRecruteurWritable } from "../models/usersRecruteur.model" import { IRoutesDef } from "./common.routes" @@ -104,10 +105,10 @@ export const zRecruiterRoutes = { }, securityScheme: null, }, - "/etablissement/cfa/:userRecruteurId/entreprises": { + "/etablissement/cfa/:cfaId/entreprises": { method: "get", - path: "/etablissement/cfa/:userRecruteurId/entreprises", - params: z.object({ userRecruteurId: zObjectId }).strict(), + path: "/etablissement/cfa/:cfaId/entreprises", + params: z.object({ cfaId: zObjectId }).strict(), response: { "200": z.array(ZRecruiter), }, @@ -115,7 +116,7 @@ export const zRecruiterRoutes = { auth: "cookie-session", access: "user:manage", resources: { - user: [{ _id: { type: "params", key: "userRecruteurId" } }], + user: [{ _id: { type: "params", key: "cfaId" } }], }, }, }, @@ -165,8 +166,9 @@ export const zRecruiterRoutes = { "200": z .object({ formulaire: ZRecruiter.optional(), - user: ZUserRecruteur, + user: ZUser2, token: z.string().optional(), + validated: z.boolean(), }) .strict(), }, diff --git a/shared/routes/trainingLinks.routes.ts b/shared/routes/trainingLinks.routes.ts index 8324667b6e..5d7b62f8fd 100644 --- a/shared/routes/trainingLinks.routes.ts +++ b/shared/routes/trainingLinks.routes.ts @@ -1,4 +1,5 @@ import { z } from "../helpers/zodWithOpenApi" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { IRoutesDef } from "./common.routes" @@ -43,6 +44,9 @@ export const zTrainingLinksRoutes = { ), }, securityScheme: null, + openapi: { + description: `${rateLimitDescription({ max: 3, timeWindow: "1s" })}`, + }, }, }, } as const satisfies IRoutesDef diff --git a/shared/routes/unsubscribe.routes.ts b/shared/routes/unsubscribe.routes.ts index 8a6b95198c..a732a55fa1 100644 --- a/shared/routes/unsubscribe.routes.ts +++ b/shared/routes/unsubscribe.routes.ts @@ -1,4 +1,5 @@ import { ZUnsubscribeQueryParams, ZUnsubscribeQueryResponse } from "../models/unsubscribeLbaCompany.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { IRoutesDef } from "./common.routes" @@ -12,6 +13,9 @@ export const zUnsubscribeRoute = { "200": ZUnsubscribeQueryResponse, }, securityScheme: null, + openapi: { + description: `${rateLimitDescription({ max: 1, timeWindow: "5s" })}`, + }, }, }, } as const satisfies IRoutesDef diff --git a/shared/routes/updateLbaCompany.routes.ts b/shared/routes/updateLbaCompany.routes.ts index 68a85663ea..ed4b3be3c4 100644 --- a/shared/routes/updateLbaCompany.routes.ts +++ b/shared/routes/updateLbaCompany.routes.ts @@ -1,5 +1,6 @@ import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { IRoutesDef } from "./common.routes" @@ -20,6 +21,9 @@ export const zUpdateLbaCompanyRoutes = { "200": z.literal("OK"), }, securityScheme: null, + openapi: { + description: `${rateLimitDescription({ max: 1, timeWindow: "20s" })}`, + }, }, }, } as const satisfies IRoutesDef diff --git a/shared/routes/user.routes.ts b/shared/routes/user.routes.ts index 577f2ff13a..5ae3fdfcb9 100644 --- a/shared/routes/user.routes.ts +++ b/shared/routes/user.routes.ts @@ -1,7 +1,11 @@ +import { OPCOS } from "../constants/recruteur" import { z } from "../helpers/zodWithOpenApi" -import { ZJob, ZRecruiter } from "../models" +import { ZJob } from "../models" import { zObjectId } from "../models/common" -import { ZEtatUtilisateur, ZUserRecruteur, ZUserRecruteurForAdmin, ZUserRecruteurWritable, ZUserStatusValidation } from "../models/usersRecruteur.model" +import { enumToZod } from "../models/enumToZod" +import { AccessEntityType, ZRoleManagement, ZRoleManagementEvent } from "../models/roleManagement.model" +import { ZUser2 } from "../models/user2.model" +import { ZEtatUtilisateur, ZUserRecruteur, ZUserRecruteurForAdmin } from "../models/usersRecruteur.model" import { IRoutesDef, ZResError } from "./common.routes" @@ -19,6 +23,7 @@ const ZUserForOpco = ZUserRecruteur.pick({ }).extend({ jobs_count: z.number(), origin: z.string(), + organizationId: zObjectId, }) export type IUserForOpco = z.output @@ -30,7 +35,7 @@ export const zUserRecruteurRoutes = { path: "/user/opco", querystring: z .object({ - opco: z.string(), + opco: enumToZod(OPCOS), }) .strict(), response: { @@ -46,7 +51,6 @@ export const zUserRecruteurRoutes = { auth: "cookie-session", access: { every: ["user:manage", "recruiter:manage"] }, resources: { - user: [{ opco: { type: "query", key: "opco" } }], recruiter: [{ opco: { type: "query", key: "opco" } }], }, }, @@ -78,7 +82,7 @@ export const zUserRecruteurRoutes = { response: { "200": z .object({ - users: z.array(ZUserRecruteur), + users: z.array(ZUser2), }) .strict(), }, @@ -97,8 +101,8 @@ export const zUserRecruteurRoutes = { }) .strict(), response: { - "200": ZUserRecruteur.extend({ - jobs: ZRecruiter.shape.jobs, + "200": ZUser2.extend({ + role: ZRoleManagement.optional(), }), }, securityScheme: { @@ -107,13 +111,14 @@ export const zUserRecruteurRoutes = { resources: {}, }, }, - "/user/:userId": { + "/user/:userId/organization/:organizationId": { method: "get", - path: "/user/:userId", + path: "/user/:userId/organization/:organizationId", // TODO_SECURITY_FIX enlever les données privées (dont last connection date) params: z .object({ userId: z.string(), + organizationId: z.string(), }) .strict(), response: { @@ -128,6 +133,7 @@ export const zUserRecruteurRoutes = { _id: { type: "params", key: "userId" }, }, ], + entreprise: [{ _id: { type: "params", key: "organizationId" } }], }, }, }, @@ -182,13 +188,15 @@ export const zUserRecruteurRoutes = { "/admin/users": { method: "post", path: "/admin/users", - body: ZUserRecruteurWritable.omit({ - is_email_checked: true, - is_qualiopi: true, - status: true, + body: ZUser2.pick({ + first_name: true, + last_name: true, + email: true, + phone: true, + origin: true, }), response: { - "200": ZUserRecruteur, + "200": z.object({ _id: zObjectId }).strict(), }, securityScheme: { auth: "cookie-session", @@ -202,7 +210,7 @@ export const zUserRecruteurRoutes = { method: "put", path: "/user/:userId", params: z.object({ userId: zObjectId }).strict(), - body: ZUserRecruteurWritable.pick({ + body: ZUser2.pick({ last_name: true, first_name: true, phone: true, @@ -220,32 +228,43 @@ export const zUserRecruteurRoutes = { }, }, }, - "/admin/users/:userId": { + "/admin/users/:userId/organization/:siret": { method: "put", - path: "/admin/users/:userId", - params: z.object({ userId: zObjectId }).strict(), - body: ZUserRecruteurWritable.partial(), + path: "/admin/users/:userId/organization/:siret", + params: z.object({ userId: zObjectId, siret: z.string() }).strict(), + body: ZUser2.omit({ + status: true, + _id: true, + }) + .extend({ + opco: ZUserRecruteur.shape.opco, + }) + .partial(), response: { "200": z.object({ ok: z.boolean() }).strict(), "400": z.union([ZResError, z.object({ error: z.boolean(), reason: z.string() }).strict()]), }, securityScheme: { auth: "cookie-session", - access: "admin", - resources: {}, + access: "user:manage", + resources: { + user: [{ _id: { type: "params", key: "userId" } }], + entreprise: [{ siret: { type: "params", key: "siret" } }], + }, }, }, - "/user/:userId/history": { + "/user/:userId/organization/:organizationId/permission": { method: "put", - path: "/user/:userId/history", - params: z.object({ userId: zObjectId }).strict(), - body: ZUserStatusValidation.pick({ - validation_type: true, + path: "/user/:userId/organization/:organizationId/permission", + params: z.object({ userId: zObjectId, organizationId: zObjectId }).strict(), + body: ZRoleManagementEvent.pick({ status: true, reason: true, + }).extend({ + organizationType: z.enum([AccessEntityType.ENTREPRISE, AccessEntityType.CFA]), }), response: { - "200": ZUserRecruteur, + "200": z.object({}).strict(), }, securityScheme: { auth: "cookie-session", diff --git a/shared/routes/v1Formations.routes.ts b/shared/routes/v1Formations.routes.ts index 3678bf0823..70cd133de2 100644 --- a/shared/routes/v1Formations.routes.ts +++ b/shared/routes/v1Formations.routes.ts @@ -1,6 +1,7 @@ import { z } from "../helpers/zodWithOpenApi" import { ZLbacError } from "../models/lbacError.model" import { ZLbaItemFormationResult } from "../models/lbaItem.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { zCallerParam, zDiplomaParam, zGetFormationOptions, ZLatitudeParam, ZLongitudeParam, ZRadiusParam, zRefererHeaders, zRomesParams } from "./_params" import { IRoutesDef, ZResError } from "./common.routes" @@ -45,7 +46,10 @@ export const zV1FormationsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Formations"] as string[], - 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\n${rateLimitDescription({ + max: 7, + timeWindow: "1s", + })}`, }, }, "/v1/formations/min": { @@ -86,7 +90,9 @@ export const zV1FormationsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Formations"] as string[], - 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\n${rateLimitDescription( + { max: 7, timeWindow: "1s" } + )}`, }, }, "/v1/formations/formation/:id": { @@ -111,7 +117,7 @@ export const zV1FormationsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Formations"] as string[], - description: "Get one formation identified by it's clé ministère éducatif", + description: `Get one formation identified by it's clé ministère éducatif\n${rateLimitDescription({ max: 7, timeWindow: "1s" })}`, }, }, }, diff --git a/shared/routes/v1FormationsParRegion.routes.ts b/shared/routes/v1FormationsParRegion.routes.ts index f6eb23bfde..59d16ca44f 100644 --- a/shared/routes/v1FormationsParRegion.routes.ts +++ b/shared/routes/v1FormationsParRegion.routes.ts @@ -1,6 +1,7 @@ import { z } from "../helpers/zodWithOpenApi" import { ZLbacError } from "../models/lbacError.model" import { ZLbaItemFormationResult } from "../models/lbaItem.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { zCallerParam, zDiplomaParam, zGetFormationOptions, zRefererHeaders } from "./_params" import { IRoutesDef, ZResError } from "./common.routes" @@ -66,7 +67,9 @@ export const zV1FormationsParRegion = { }, securityScheme: null, openapi: { - 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\n${rateLimitDescription( + { max: 5, timeWindow: "1s" } + )}`, tags: ["V1 - Formations par région"] as string[], }, }, diff --git a/shared/routes/v1Jobs.routes.ts b/shared/routes/v1Jobs.routes.ts index 18fc95bd29..d25e100cda 100644 --- a/shared/routes/v1Jobs.routes.ts +++ b/shared/routes/v1Jobs.routes.ts @@ -5,6 +5,7 @@ import { zObjectId } from "../models/common" import { ZApiError, ZLbacError, ZLbarError } from "../models/lbacError.model" import { ZLbaItemFtJob, ZLbaItemLbaCompany, ZLbaItemLbaJob } from "../models/lbaItem.model" import { ZRecruiter } from "../models/recruiter.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { zCallerParam, @@ -55,7 +56,7 @@ export const zV1JobsRoutes = { }, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Get existing establishment id from siret & email", + description: `Get existing establishment id from siret & email\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/bulk": { @@ -121,7 +122,7 @@ export const zV1JobsRoutes = { }, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Get all jobs related to my organization", + description: `Get all jobs related to my organization\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/delegations/:jobId": { @@ -157,7 +158,7 @@ export const zV1JobsRoutes = { }, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Get related training organization related to a job offer.", + description: `Get related training organization related to a job offer.\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs": { @@ -219,7 +220,7 @@ export const zV1JobsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Get job opportunities matching the query parameters", + description: `Get job opportunities matching the query parameters\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/min": { @@ -281,7 +282,7 @@ export const zV1JobsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Get job opportunities matching the query parameters", + description: `Get job opportunities matching the query parameters\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/company/:siret": { @@ -312,7 +313,7 @@ export const zV1JobsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Get one company identified by it's siret", + description: `Get one company identified by it's siret\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/matcha/:id": { @@ -347,7 +348,7 @@ export const zV1JobsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Get one lba job identified by it's id", + description: `Get one lba job identified by it's id\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/job/:id": { @@ -378,7 +379,7 @@ export const zV1JobsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Get one pe job identified by it's id", + description: `Get one pe job identified by it's id\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, }, @@ -416,7 +417,7 @@ export const zV1JobsRoutes = { }, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Create an establishment entity", + description: `Create an establishment entity\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/:establishmentId": { @@ -437,7 +438,7 @@ export const zV1JobsRoutes = { }) .extend({ job_start_date: ZJobStartDateCreate(), - appellation_code: z.string().regex(/^[0-9]+$/, "appelation code must contains only numbers"), + appellation_code: z.string().regex(/^[0-9]+$/, "appelation code ne doit contenir que des chiffres"), }) .strict() .refine( @@ -447,7 +448,25 @@ export const zV1JobsRoutes = { } return true }, - { message: "custom_geo_coordinates must be filled if a custom_address is passed" } + { message: "custom_geo_coordinates est obligatoire si custom_address est passé en paramètre" } + ) + .refine( + ({ job_description }) => { + if (job_description && job_description?.length < 30) { + return false + } + return true + }, + { message: "job_description doit avoir un minimum de 30 caractères" } + ) + .refine( + ({ job_employer_description }) => { + if (job_employer_description && job_employer_description?.length < 30) { + return false + } + return true + }, + { message: "job_employer_description doit avoir un minimum de 30 caractères" } ), response: { "201": ZRecruiter, @@ -466,7 +485,7 @@ export const zV1JobsRoutes = { }, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Create a job offer inside an establishment entity.", + description: `Create a job offer inside an establishment entity.\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/delegations/:jobId": { @@ -518,7 +537,7 @@ export const zV1JobsRoutes = { }, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Update a job offer status to Provided", + description: `Update a job offer status to Provided\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/canceled/:jobId": { @@ -541,7 +560,7 @@ export const zV1JobsRoutes = { }, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Update a job offer status to Canceled", + description: `Update a job offer status to Canceled\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/extend/:jobId": { @@ -564,7 +583,7 @@ export const zV1JobsRoutes = { }, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Update a job expiration date by 30 days.", + description: `Update a job expiration date by 30 days.\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, "/v1/jobs/matcha/:id/stats/view-details": { @@ -581,7 +600,7 @@ export const zV1JobsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Notifies that the detail of a matcha job has been viewed", + description: `Notifies that the detail of a matcha job has been viewed\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, }, @@ -623,7 +642,7 @@ export const zV1JobsRoutes = { }, openapi: { tags: ["V1 - Jobs"] as string[], - description: "Update a job offer specific fields inside an establishment entity.", + description: `Update a job offer specific fields inside an establishment entity.\n${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, }, diff --git a/shared/routes/v1JobsEtFormations.routes.ts b/shared/routes/v1JobsEtFormations.routes.ts index 56de3e4ccf..c6c73a84f6 100644 --- a/shared/routes/v1JobsEtFormations.routes.ts +++ b/shared/routes/v1JobsEtFormations.routes.ts @@ -1,6 +1,7 @@ import { z } from "../helpers/zodWithOpenApi" import { ZApiError, ZLbacError } from "../models/lbacError.model" import { ZLbaItemFormation, ZLbaItemLbaCompany, ZLbaItemLbaJob, ZLbaItemFtJob } from "../models/lbaItem.model" +import { rateLimitDescription } from "../utils/rateLimitDescription" import { zCallerParam, @@ -95,6 +96,7 @@ export const zV1JobsEtFormationsRoutes = { securityScheme: null, openapi: { tags: ["V1 - Jobs et formations"] as string[], + description: `${rateLimitDescription({ max: 5, timeWindow: "1s" })}`, }, }, }, diff --git a/shared/security/permissions.ts b/shared/security/permissions.ts index 208143986f..20aec0eb51 100644 --- a/shared/security/permissions.ts +++ b/shared/security/permissions.ts @@ -70,12 +70,15 @@ export type AccessRessouces = { application?: ReadonlyArray<{ _id: AccessResourcePath }> - user?: ReadonlyArray< + user?: ReadonlyArray<{ + _id: AccessResourcePath + }> + entreprise?: ReadonlyArray< | { _id: AccessResourcePath } | { - opco: AccessResourcePath + siret: AccessResourcePath } > } diff --git a/shared/utils/enumUtils.ts b/shared/utils/enumUtils.ts new file mode 100644 index 0000000000..e9e9a1f724 --- /dev/null +++ b/shared/utils/enumUtils.ts @@ -0,0 +1,12 @@ +export const parseEnum = (enumObj: Record, value: string | null | undefined): T | null => { + return Object.values(enumObj).find((enumValue) => enumValue.toLowerCase() === value?.toLowerCase()) ?? null +} +export const isEnum = (enumValues: Record, value: unknown): value is T => typeof value === "string" && parseEnum(enumValues, value) !== null + +export const parseEnumOrError = (enumObj: Record, value: string | null): T => { + const enumValue = parseEnum(enumObj, value) + if (enumValue === null) { + throw new Error(`could not parse ${value} as enum ${JSON.stringify(Object.values(enumObj))}`) + } + return enumValue +} diff --git a/shared/utils/getLastStatusEvent.test.ts b/shared/utils/getLastStatusEvent.test.ts new file mode 100644 index 0000000000..6a07e39059 --- /dev/null +++ b/shared/utils/getLastStatusEvent.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest" + +import { getLastStatusEvent } from "./getLastStatusEvent" + +describe("getLastStatusEvent", () => { + it("sort events by date", () => { + expect( + getLastStatusEvent([ + { + date: new Date(5), + status: "A", + }, + { + date: new Date(3), + status: "B", + }, + { + date: new Date(4), + status: "C", + }, + ])?.status + ).toEqual("A") + }) +}) diff --git a/shared/utils/getLastStatusEvent.ts b/shared/utils/getLastStatusEvent.ts new file mode 100644 index 0000000000..47f7e100f8 --- /dev/null +++ b/shared/utils/getLastStatusEvent.ts @@ -0,0 +1,13 @@ +export const getLastStatusEvent = (stateArray?: T[]): T | null => { + if (!stateArray) { + return null + } + const sortedArray = [...stateArray].sort((a, b) => { + return new Date(a?.date ?? 0).valueOf() < new Date(b?.date ?? 0).valueOf() ? -1 : 1 + }) + const lastValidationEvent = sortedArray.at(sortedArray.length - 1) + if (!lastValidationEvent) { + return null + } + return lastValidationEvent.status ? lastValidationEvent : null +} diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 313dfbace4..e8f5eb2835 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -1,2 +1,5 @@ export * from "./assertUnreachable" +export * from "./enumUtils" +export * from "./getLastStatusEvent" export * from "./stringUtils" +export * from "./objectUtils" diff --git a/shared/utils/objectUtils.ts b/shared/utils/objectUtils.ts index 01d2aaf4a2..1ebcd26e9a 100644 --- a/shared/utils/objectUtils.ts +++ b/shared/utils/objectUtils.ts @@ -3,3 +3,7 @@ export const typedKeys = (record: Record(entries: [A, B][]): Record => { return Object.fromEntries(entries) as Record } + +export function removeUndefinedFields>(obj: T): T { + return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined)) as T +} diff --git a/shared/utils/rateLimitDescription.ts b/shared/utils/rateLimitDescription.ts new file mode 100644 index 0000000000..37f3a09f9c --- /dev/null +++ b/shared/utils/rateLimitDescription.ts @@ -0,0 +1,8 @@ +export const rateLimitDescription = ({ max, timeWindow }: { max: number; timeWindow: string }) => { + const match = new RegExp("^([0-9]+)s$", "gi").exec(timeWindow) + if (!match) { + throw new Error(`timeWindow format unsupported: ${timeWindow}`) + } + const timeWindowInSeconds = parseInt(match[1], 10) + return `Limite : ${max} appel(s) / ${timeWindowInSeconds} seconde(s)\n` +} diff --git a/ui/common/SSR/getAuthServerSideProps.ts b/ui/common/SSR/getAuthServerSideProps.ts index ebee978db0..f45e16624b 100644 --- a/ui/common/SSR/getAuthServerSideProps.ts +++ b/ui/common/SSR/getAuthServerSideProps.ts @@ -9,15 +9,22 @@ export const getAuthServerSideProps = async (context) => { return {} } try { - const session: IUserRecruteurPublic = await apiGet( + const user: IUserRecruteurPublic = await apiGet( `/auth/session`, {}, { headers: context.req.headers, } ) - return { auth: session } + const userAccess = await apiGet( + `/auth/access`, + {}, + { + headers: context.req.headers, + } + ) + return { user, userAccess } } catch (e) { - return { auth: null } + return { user: null, userAccess: null } } } diff --git a/ui/common/hooks/useUserHistoryUpdate.ts b/ui/common/hooks/useUserHistoryUpdate.ts index a9f7d9dedc..e144effef4 100644 --- a/ui/common/hooks/useUserHistoryUpdate.ts +++ b/ui/common/hooks/useUserHistoryUpdate.ts @@ -1,32 +1,43 @@ import { useToast } from "@chakra-ui/react" import { useCallback } from "react" import { useQueryClient } from "react-query" -import { ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" - -import { useAuth } from "@/context/UserContext" +import { assertUnreachable } from "shared" +import { AccessStatus } from "shared/models/roleManagement.model" import { updateUserValidationHistory } from "../../utils/api" -export default function useUserHistoryUpdate(userId: string, status: ETAT_UTILISATEUR, reason?: string) { - const { user } = useAuth() +export default function useUserHistoryUpdate() { const client = useQueryClient() const toast = useToast() - return useCallback(async () => { - await updateUserValidationHistory(userId, { - validation_type: VALIDATION_UTILISATEUR.MANUAL, - status, - reason, - }) - .then(() => ["user-list-opco", "user-list", "user"].map((x) => client.invalidateQueries(x))) - .then(() => - toast({ - description: `Utilisateur ${status}`, - position: "top-right", - status: "success", - duration: 4000, - isClosable: true, + return useCallback( + async (props: Parameters[0]) => { + await updateUserValidationHistory(props) + .then(() => ["user-list-opco", "user-list", "user"].map((x) => client.invalidateQueries(x))) + .then(() => { + const newStatus = props.status + toast({ + description: `Utilisateur ${getDescription(newStatus)}`, + position: "top-right", + status: "success", + duration: 4000, + isClosable: true, + }) }) - ) - }, [user._id, client, reason, status, toast, userId]) + }, + [client, toast] + ) +} + +const getDescription = (status: AccessStatus): string => { + switch (status) { + case AccessStatus.GRANTED: + return "validé" + case AccessStatus.DENIED: + return "désactivé" + case AccessStatus.AWAITING_VALIDATION: + return "en attente de validation" + default: + assertUnreachable(status) + } } diff --git a/ui/common/hooks/useUserPermissionsActions.ts b/ui/common/hooks/useUserPermissionsActions.ts new file mode 100644 index 0000000000..ac606baa75 --- /dev/null +++ b/ui/common/hooks/useUserPermissionsActions.ts @@ -0,0 +1,47 @@ +import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" + +import { updateUserValidationHistory } from "@/utils/api" + +import useUserHistoryUpdate from "./useUserHistoryUpdate" + +type UpdateProps = Parameters[0] + +export const useUserPermissionsActions = ( + userId: UpdateProps["userId"], + organizationId: UpdateProps["organizationId"] = userId, // TODO not passed and not processed by API. It should be a valid ObjectId since it is still validated by zod. + organizationType: AccessEntityType = AccessEntityType.ENTREPRISE // TODO not passed and not processed by API +) => { + const update = useUserHistoryUpdate() + const acceptedTypes = [AccessEntityType.ENTREPRISE, AccessEntityType.CFA] + if (!acceptedTypes.includes(organizationType)) { + throw new Error(`organizationType doit être dans ${acceptedTypes.join(", ")}`) + } + const checkedType = organizationType as typeof AccessEntityType.ENTREPRISE | typeof AccessEntityType.CFA + return { + activate: (reason = "") => + update({ + userId, + organizationId, + organizationType: checkedType, + status: AccessStatus.GRANTED, + reason, + }), + + deactivate: (reason: string) => + update({ + userId, + organizationId, + organizationType: checkedType, + status: AccessStatus.DENIED, + reason, + }), + waitsForValidation: (reason: string) => + update({ + userId, + organizationId, + organizationType: checkedType, + status: AccessStatus.AWAITING_VALIDATION, + reason, + }), + } +} diff --git a/ui/common/utils/debounce.ts b/ui/common/utils/debounce.ts index 767593bf31..8d5bb544ed 100644 --- a/ui/common/utils/debounce.ts +++ b/ui/common/utils/debounce.ts @@ -4,14 +4,16 @@ export function debounce(callback, delay) { return (...args) => { return new Promise((resolve, reject) => { clearTimeout(timer) - timer = setTimeout(() => { + const localTimer = setTimeout(() => { try { - const output = callback(...args) - resolve(output) + callback(...args) + .then((output) => localTimer === timer && resolve(output)) + .catch((err) => localTimer === timer && reject(err)) } catch (err) { reject(err) } }, delay) + timer = localTimer }) } } diff --git a/ui/components/DepotOffre/InfosDiffusionOffre.tsx b/ui/components/DepotOffre/InfosDiffusionOffre.tsx new file mode 100644 index 0000000000..48f2e89418 --- /dev/null +++ b/ui/components/DepotOffre/InfosDiffusionOffre.tsx @@ -0,0 +1,26 @@ +import { Box, Flex, Heading, Image, Stack, Text } from "@chakra-ui/react" + +import { InfoCircle } from "@/theme/components/icons" +import { J1S, Parcoursup } from "@/theme/components/logos_pro" + +export const InfosDiffusionOffre = () => { + return ( + + + Dites-nous en plus sur votre besoin en recrutement + + + + Cela permettra à votre offre d'être visible des candidats intéressés. + + + Une fois créée, votre offre d’emploi sera immédiatement mise en ligne sur les sites suivants : + + + + + + + + ) +} diff --git a/ui/components/DepotOffre/RomeDetail.tsx b/ui/components/DepotOffre/RomeDetail.tsx new file mode 100644 index 0000000000..a1369fb915 --- /dev/null +++ b/ui/components/DepotOffre/RomeDetail.tsx @@ -0,0 +1,96 @@ +import { Accordion, AccordionButton, AccordionItem, AccordionPanel, Box, Flex, Heading, Text } from "@chakra-ui/react" + +import { InfoCircle, Minus, Plus } from "@/theme/components/icons" + +export const RomeDetail = ({ definition, competencesDeBase, libelle, appellation, acces }) => { + const definitionSplitted = definition.split("\\n") + const accesFormatted = acces.split("\\n").join("

") + + return ( + + + + {appellation} + + + Fiche métier : {libelle} + + La fiche métier se base sur la classification ROME de France Travail + + + + Voici la description visible par les candidats lors de la mise en ligne de l’offre d’emploi en alternance. + + + + + {({ isExpanded }) => ( + <> +

+ + + Description du métier + + {isExpanded ? : } + +

+ +
    + {definitionSplitted.map((x) => { + return ( +
  • + {x} +
  • + ) + })} +
+
+ + )} +
+
+ + {({ isExpanded }) => ( + <> +

+ + + Quelles sont les compétences visées ? + + {isExpanded ? : } + +

+ +
    + {competencesDeBase.map((x) => ( +
  • + {x.libelle} +
  • + ))} +
+
+ + )} +
+
+ + {({ isExpanded }) => ( + <> +

+ + + À qui ce métier est-il accessible ? + + {isExpanded ? : } + +

+ + + + + )} +
+
+
+ ) +} diff --git a/ui/components/DepotOffre/RomeDetailWithQuery.tsx b/ui/components/DepotOffre/RomeDetailWithQuery.tsx new file mode 100644 index 0000000000..a4e03f0a08 --- /dev/null +++ b/ui/components/DepotOffre/RomeDetailWithQuery.tsx @@ -0,0 +1,40 @@ +import { ExternalLinkIcon } from "@chakra-ui/icons" +import { Flex, Spinner, Text, Box, Heading, Link } from "@chakra-ui/react" +import { useQuery } from "react-query" + +import { getRomeDetail } from "@/utils/api" + +import { RomeDetail } from "./RomeDetail" + +export const RomeDetailWithQuery = ({ rome, appellation }: { rome: string; appellation: any }) => { + const { data, isLoading, error } = useQuery(["getRomeDetail", rome, appellation], () => getRomeDetail(rome), { + retry: false, + }) + + return isLoading ? ( + + + Recherche en cours... + + ) : error ? ( + + + {appellation} + + + La fiche métier n'a pas pu être trouvée, merci de le{" "} + + signaler à notre équipe support + {" "} + en précisant le métier cherché + + + ) : ( + + ) +} diff --git a/ui/components/DesinscriptionEntreprise/FormulaireDesinscription.tsx b/ui/components/DesinscriptionEntreprise/FormulaireDesinscription.tsx index 4ae38289f8..dbc9cafc5f 100644 --- a/ui/components/DesinscriptionEntreprise/FormulaireDesinscription.tsx +++ b/ui/components/DesinscriptionEntreprise/FormulaireDesinscription.tsx @@ -232,7 +232,7 @@ const FormulaireDesinscription = ({ handleUnsubscribeSuccess }) => {
0}> 0 && errors.email ? "red.500" : "gray.800"}>Email de l'établissement - Indiquer l'email sur lequel sont actuellement reçues les candidatures + Indiquez l'email sur lequel sont actuellement reçues les candidatures {({ field }) => { return @@ -251,7 +251,7 @@ const FormulaireDesinscription = ({ handleUnsubscribeSuccess }) => { 0}> 0 && errors.reason ? "red.500" : "gray.800"}>Motif - Indiquer la raison pour laquelle vous ne souhaitez plus recevoir de candidature? + Indiquez la raison pour laquelle vous ne souhaitez plus recevoir de candidature {({ field }) => { return ( diff --git a/ui/components/ItemDetail/CandidatureLba/WidgetPostuler.tsx b/ui/components/ItemDetail/CandidatureLba/WidgetPostuler.tsx index c13d490c70..7a43262138 100644 --- a/ui/components/ItemDetail/CandidatureLba/WidgetPostuler.tsx +++ b/ui/components/ItemDetail/CandidatureLba/WidgetPostuler.tsx @@ -1,10 +1,12 @@ +import { assertUnreachable } from "@/../shared" import { Flex, Spinner } from "@chakra-ui/react" -import axios from "axios" import { useEffect, useState } from "react" import { LBA_ITEM_TYPE_OLD } from "shared/constants/lbaitem" +import fetchLbaCompanyDetails from "@/services/fetchLbaCompanyDetails" +import fetchLbaJobDetails from "@/services/fetchLbaJobDetails" + import { initPostulerParametersFromQuery } from "../../../services/config" -import { companyApi, matchaApi } from "../../SearchForTrainingsAndJobs/services/utils" import WidgetCandidatureLba from "./WidgetCandidatureLba" import WidgetPostulerError from "./WidgetPostulerError" @@ -24,21 +26,15 @@ const WidgetPostuler = () => { switch (parameters.type) { case LBA_ITEM_TYPE_OLD.MATCHA: { - const response = await axios.get(matchaApi + "/" + parameters.itemId) - if (!response?.data?.message) { - item = response.data.matchas[0] - } - + item = await fetchLbaJobDetails({ id: parameters.itemId }) + break + } + case LBA_ITEM_TYPE_OLD.LBA: { + item = await fetchLbaCompanyDetails({ id: parameters.itemId }) break } - default: { - const response = await axios.get(`${companyApi}/${parameters.itemId}?type=${parameters.type}`) - if (!response?.data?.message) { - const companies = response.data.lbaCompanies - item = companies[0] - } - + assertUnreachable("shouldNotHappen" as never) break } } diff --git a/ui/components/ItemDetail/FTJobDetail.tsx b/ui/components/ItemDetail/FTJobDetail.tsx index 6c1d884785..8edb12b56c 100644 --- a/ui/components/ItemDetail/FTJobDetail.tsx +++ b/ui/components/ItemDetail/FTJobDetail.tsx @@ -16,7 +16,7 @@ const FTJobDetail = ({ job }) => { }, []) // Utiliser le useEffect une seule fois : https://css-tricks.com/run-useeffect-only-once/ useEffect(() => { - SendPlausibleEvent("Affichage - Fiche entreprise Offre PE", { + SendPlausibleEvent("Affichage - Fiche entreprise Offre FT", { info_fiche: `${job?.job?.id}${formValues?.job?.label ? ` - ${formValues.job.label}` : ""}`, }) }, [job?.job?.id]) diff --git a/ui/components/ItemDetail/GoingToContactQuestion.tsx b/ui/components/ItemDetail/GoingToContactQuestion.tsx index 11d39c85b2..f21ecf5eb0 100644 --- a/ui/components/ItemDetail/GoingToContactQuestion.tsx +++ b/ui/components/ItemDetail/GoingToContactQuestion.tsx @@ -15,7 +15,7 @@ const GoingToContactQuestion = ({ kind, uniqId, item }) => { return "formation" } if (kind === LBA_ITEM_TYPE_OLD.PEJOB) { - return "entreprise Offre PE" + return "entreprise Offre FT" } return "entreprise Algo" } diff --git a/ui/components/ItemDetail/ItemDetail.tsx b/ui/components/ItemDetail/ItemDetail.tsx index 57f32907dd..713a96446e 100644 --- a/ui/components/ItemDetail/ItemDetail.tsx +++ b/ui/components/ItemDetail/ItemDetail.tsx @@ -1,16 +1,18 @@ import { assertUnreachable } from "@/../shared" import { Box, Flex, Spinner, Text } from "@chakra-ui/react" -import { useContext } from "react" +import { useContext, useState } from "react" import { useQuery } from "react-query" import { LBA_ITEM_TYPE_OLD } from "shared/constants/lbaitem" import fetchFtJobDetails from "@/services/fetchFtJobDetails" import fetchLbaCompanyDetails from "@/services/fetchLbaCompanyDetails" import fetchLbaJobDetails from "@/services/fetchLbaJobDetails" +import { ApiError } from "@/utils/api.utils" import { DisplayContext } from "../../context/DisplayContextProvider" import { SearchResultContext } from "../../context/SearchResultContextProvider" import fetchTrainingDetails from "../../services/fetchTrainingDetails" +import ErrorMessage from "../ErrorMessage" import getActualTitle from "./ItemDetailServices/getActualTitle" import { BuildSwipe, getNavigationButtons } from "./ItemDetailServices/getButtons" @@ -19,11 +21,11 @@ import getSoustitre from "./ItemDetailServices/getSoustitre" import getTags from "./ItemDetailServices/getTags" import LoadedItemDetail from "./loadedItemDetail" -const getItemDetails = async ({ selectedItem, trainings, jobs, setTrainingsAndSelectedItem, setJobsAndSelectedItem }) => { +const getItemDetails = async ({ selectedItem, trainings, jobs, setTrainingsAndSelectedItem, setJobsAndSelectedItem, setHasError }) => { + setHasError("") switch (selectedItem?.ideaType) { case LBA_ITEM_TYPE_OLD.FORMATION: { const trainingWithDetails = await fetchTrainingDetails(selectedItem) - trainingWithDetails.detailsLoaded = true const updatedTrainings = trainings.map((v) => { if (v.id === trainingWithDetails.id) { return trainingWithDetails @@ -37,7 +39,6 @@ const getItemDetails = async ({ selectedItem, trainings, jobs, setTrainingsAndSe case LBA_ITEM_TYPE_OLD.MATCHA: { const jobWithDetails = await fetchLbaJobDetails(selectedItem) - jobWithDetails.detailsLoaded = true const updatedJobs = { peJobs: jobs.peJobs, lbaCompanies: jobs.lbaCompanies, @@ -55,7 +56,6 @@ const getItemDetails = async ({ selectedItem, trainings, jobs, setTrainingsAndSe case LBA_ITEM_TYPE_OLD.LBA: { const companyWithDetails = await fetchLbaCompanyDetails(selectedItem) - companyWithDetails.detailsLoaded = true const updatedJobs = { peJobs: jobs.peJobs, lbaCompanies: jobs.lbaCompanies.map((v) => { @@ -73,7 +73,6 @@ const getItemDetails = async ({ selectedItem, trainings, jobs, setTrainingsAndSe case LBA_ITEM_TYPE_OLD.PEJOB: { const jobWithDetails = await fetchFtJobDetails(selectedItem) - jobWithDetails.detailsLoaded = true const updatedJobs = { peJobs: jobs.peJobs.map((v) => { if (v.id === jobWithDetails.id) { @@ -101,14 +100,17 @@ const ItemDetail = ({ selectedItem, handleClose, handleSelectItem }) => { const actualTitle = getActualTitle({ kind, selectedItem }) const { activeFilters } = useContext(DisplayContext) + const [hasError, setHasError] = useState<"not_found" | "unexpected" | "">("") + const { trainings, setTrainingsAndSelectedItem, jobs, setJobsAndSelectedItem, extendedSearch } = useContext(SearchResultContext) const currentList = getCurrentList({ store: { trainings, jobs }, activeFilters, extendedSearch }) const { swipeHandlers, goNext, goPrev } = BuildSwipe({ currentList, handleSelectItem, selectedItem }) const kindColor = kind !== LBA_ITEM_TYPE_OLD.FORMATION ? "pinksoft.600" : "greensoft.500" - useQuery(["itemDetail", selectedItem.id], () => getItemDetails({ selectedItem, trainings, jobs, setTrainingsAndSelectedItem, setJobsAndSelectedItem }), { + useQuery(["itemDetail", selectedItem.id], () => getItemDetails({ selectedItem, trainings, jobs, setTrainingsAndSelectedItem, setJobsAndSelectedItem, setHasError }), { enabled: !!selectedItem && !selectedItem.detailsLoaded, + onError: (error: ApiError) => setHasError(error.isNotFoundError() ? "not_found" : "unexpected"), }) return selectedItem?.detailsLoaded ? ( @@ -158,10 +160,14 @@ const ItemDetail = ({ selectedItem, handleClose, handleSelectItem }) => { - - Chargement des informations en cours - - + {hasError ? ( + + ) : ( + + Chargement des informations en cours + + + )} ) diff --git a/ui/components/ItemDetail/loadedItemDetail.tsx b/ui/components/ItemDetail/loadedItemDetail.tsx index fe9fdee952..2e2dd66859 100644 --- a/ui/components/ItemDetail/loadedItemDetail.tsx +++ b/ui/components/ItemDetail/loadedItemDetail.tsx @@ -65,7 +65,7 @@ const LoadedItemDetail = ({ selectedItem, handleClose, handleSelectItem }) => { } const postuleSurFranceTravail = () => { - SendPlausibleEvent("Clic Postuler - Fiche entreprise Offre PE", { + SendPlausibleEvent("Clic Postuler - Fiche entreprise Offre FT", { info_fiche: selectedItem.job.id, }) } diff --git a/ui/components/RDV/types.ts b/ui/components/RDV/types.ts index 8599a0c9f2..1ec8f319a1 100644 --- a/ui/components/RDV/types.ts +++ b/ui/components/RDV/types.ts @@ -50,13 +50,13 @@ const reasons = [ checked: false, }, { - key: EReasonsKey.CONTENU, - title: "Contenu de la formation", + key: EReasonsKey.PLACE, + title: "Places disponibles", checked: false, }, { - key: EReasonsKey.PORTE, - title: "Portes ouvertes", + key: EReasonsKey.ACCOMPAGNEMENT, + title: "Accompagnement dans la recherche d'entreprise", checked: false, }, { @@ -65,8 +65,8 @@ const reasons = [ checked: false, }, { - key: EReasonsKey.PLACE, - title: "Places disponibles", + key: EReasonsKey.CONTENU, + title: "Contenu de la formation", checked: false, }, { @@ -80,8 +80,8 @@ const reasons = [ checked: false, }, { - key: EReasonsKey.ACCOMPAGNEMENT, - title: "Accompagnement dans la recherche d'entreprise", + key: EReasonsKey.SUIVI, + title: "Suivi de ma candidature", checked: false, }, { @@ -90,8 +90,8 @@ const reasons = [ checked: false, }, { - key: EReasonsKey.SUIVI, - title: "Suivi de ma candidature", + key: EReasonsKey.PORTE, + title: "Portes ouvertes", checked: false, }, { diff --git a/ui/components/SearchForTrainingsAndJobs/components/ResultLists.tsx b/ui/components/SearchForTrainingsAndJobs/components/ResultLists.tsx index e644587345..eb04aed378 100644 --- a/ui/components/SearchForTrainingsAndJobs/components/ResultLists.tsx +++ b/ui/components/SearchForTrainingsAndJobs/components/ResultLists.tsx @@ -9,7 +9,7 @@ import { SearchResultContext } from "../../../context/SearchResultContextProvide import { isCfaEntreprise } from "../../../services/cfaEntreprise" import { mergeJobs, mergeOpportunities } from "../../../utils/itemListUtils" import { renderJob, renderLbb, renderTraining } from "../services/renderOneResult" -import { allJobSearchErrorText, getJobCount, partialJobSearchErrorText } from "../services/utils" +import { allJobSearchErrorText, getJobCount } from "../services/utils" import ExtendedSearchButton from "./ExtendedSearchButton" import NoJobResult from "./NoJobResult" @@ -237,7 +237,7 @@ const ResultLists = ({ <> {trainingSearchError && } {jobSearchError && partnerJobSearchError && } - {(jobSearchError ^ partnerJobSearchError) === 1 && } + {((jobSearchError && !partnerJobSearchError) || (!jobSearchError && partnerJobSearchError)) && } ) } diff --git a/ui/components/SearchForTrainingsAndJobs/services/loadItem.ts b/ui/components/SearchForTrainingsAndJobs/services/loadItem.ts index 96fc51c770..33750535aa 100644 --- a/ui/components/SearchForTrainingsAndJobs/services/loadItem.ts +++ b/ui/components/SearchForTrainingsAndJobs/services/loadItem.ts @@ -1,6 +1,11 @@ -import axios from "axios" +import { assertUnreachable } from "@/../shared" import { LBA_ITEM_TYPE_OLD } from "shared/constants/lbaitem" +import fetchFtJobDetails from "@/services/fetchFtJobDetails" +import fetchLbaCompanyDetails from "@/services/fetchLbaCompanyDetails" +import fetchLbaJobDetails from "@/services/fetchLbaJobDetails" +import fetchTrainingDetails from "@/services/fetchTrainingDetails" + import { flyToMarker, layerType, @@ -16,7 +21,7 @@ import { logError } from "../../../utils/tools" import { storeTrainingsInSession } from "./handleSessionStorage" import { searchForJobsFunction, searchForPartnerJobsFunction } from "./searchForJobs" -import { companyApi, matchaApi, notFoundErrorText, offreApi, partialJobSearchErrorText, trainingApi, trainingErrorText } from "./utils" +import { notFoundErrorText, partialJobSearchErrorText, trainingErrorText } from "./utils" export const loadItem = async ({ item, @@ -42,24 +47,16 @@ export const loadItem = async ({ let itemMarker = null if (item.type === "training") { - const response = await axios.get(`${trainingApi}/${encodeURIComponent(item.itemId)}`) - - if (response.data.result === "error") { - logError("Training Search Error", `${response.data.message}`) - setTrainingSearchError(trainingErrorText) - } - + const training = await fetchTrainingDetails({ id: item.itemId }) const searchTimestamp = new Date().getTime() - setTrainings(response.data.results) - storeTrainingsInSession({ trainings: response.data.results, searchTimestamp }) + setTrainings([training]) + storeTrainingsInSession({ trainings: [training], searchTimestamp }) - if (response.data.results.length) { - setTrainingMarkers({ trainingList: factorTrainingsForMap(response.data.results) }) - } - setSelectedItem(response.data.results[0]) - setSelectedMarker(response.data.results[0]) - itemMarker = response.data.results[0] + setTrainingMarkers({ trainingList: factorTrainingsForMap([training]) }) + setSelectedItem(training) + setSelectedMarker(training) + itemMarker = training // lancement d'une recherche d'emploi autour de la formation chargée const values = { @@ -110,59 +107,33 @@ export const loadItem = async ({ let loadedItem = null - let errorMessage = null - let responseResult = null - - switch (item.type) { - case LBA_ITEM_TYPE_OLD.PEJOB: { - const response = await axios.get(offreApi + "/" + item.itemId) - - // gestion des erreurs - if (!response?.data?.message) { - const peJobs = await computeMissingPositionAndDistance(null, response.data.peJobs) - - results.peJobs = peJobs - loadedItem = peJobs[0] - } else { - errorMessage = `PE Error : ${response.data.message}` - responseResult = response.data.result + try { + switch (item.type) { + case LBA_ITEM_TYPE_OLD.PEJOB: { + const ftJob = await fetchFtJobDetails({ id: item.itemId }) + const ftJobs = await computeMissingPositionAndDistance(null, [ftJob]) + results.peJobs = ftJobs + loadedItem = ftJobs[0] + break } - break - } - case LBA_ITEM_TYPE_OLD.MATCHA: { - const matchaUrl = `${matchaApi}/${item.itemId}` - const response = await axios.get(matchaUrl) - - // gestion des erreurs - if (!response?.data?.message) { - const matchas = await computeMissingPositionAndDistance(null, response.data.matchas) - results.matchas = matchas - loadedItem = matchas[0] - } else { - errorMessage = `Matcha Error : ${response.data.message}` - responseResult = response.data.result + case LBA_ITEM_TYPE_OLD.MATCHA: { + const lbaJob = await fetchLbaJobDetails({ id: item.itemId }) + const lbaJobs = await computeMissingPositionAndDistance(null, [lbaJob]) + results.matchas = lbaJobs + loadedItem = lbaJobs[0] + break } - break - } - - default: { - const response = await axios.get(`${companyApi}/${item.itemId}`) - - // gestion des erreurs - if (!response?.data?.message) { - const companies = response.data.lbaCompanies - - loadedItem = companies[0] - results.lbaCompanies = companies - } else { - errorMessage = `${item.type} Error : ${response.data.message}` - responseResult = response.data.result + case LBA_ITEM_TYPE_OLD.LBA: { + const lbaCompany = await fetchLbaCompanyDetails({ id: item.itemId }) + results.matchas = [lbaCompany] + loadedItem = lbaCompany + break + } + default: { + assertUnreachable("should not happen" as never) } - break } - } - if (!errorMessage) { setJobs(results) setHasSearch(true) @@ -176,9 +147,8 @@ export const loadItem = async ({ setSelectedItem(loadedItem) setSelectedMarker(loadedItem) itemMarker = loadedItem - } else { - logError("Job Load Error", errorMessage) - setJobSearchError(responseResult === "not_found" ? notFoundErrorText : partialJobSearchErrorText) + } catch (directElementLoadError) { + setJobSearchError(directElementLoadError.isNotFoundError() ? notFoundErrorText : partialJobSearchErrorText) } } diff --git a/ui/components/SearchForTrainingsAndJobs/services/utils.ts b/ui/components/SearchForTrainingsAndJobs/services/utils.ts index a295e8ab3d..858dce2f7c 100644 --- a/ui/components/SearchForTrainingsAndJobs/services/utils.ts +++ b/ui/components/SearchForTrainingsAndJobs/services/utils.ts @@ -3,12 +3,7 @@ import { publicConfig } from "@/config.public" const { apiEndpoint } = publicConfig const trainingsApi = "/v1/formations/min" -const trainingApi = `${apiEndpoint}/v1/formations/formation` -const jobsApi = `${apiEndpoint}/v1/jobs` -const minimalDataJobsApi = jobsApi + "/min" -const offreApi = jobsApi + "/job" -const matchaApi = jobsApi + "/matcha" -const companyApi = jobsApi + "/company" +const minimalDataJobsApi = `${apiEndpoint}/v1/jobs/min` const allJobSearchErrorText = "Problème momentané d'accès aux opportunités d'emploi" const partialJobSearchErrorText = "Problème momentané d'accès à certaines opportunités d'emploi" @@ -52,19 +47,14 @@ const defaultFilters = ["jobs", "trainings", "duo"] export { allJobSearchErrorText, - companyApi, defaultFilters, getJobCount, getPartnerJobCount, getRomeFromParameters, - jobsApi, minimalDataJobsApi, - matchaApi, notFoundErrorText, - offreApi, partialJobSearchErrorText, technicalErrorText, - trainingApi, trainingErrorText, trainingsApi, } diff --git a/ui/components/espace_pro/Admin/utilisateurs/AdminUserForm.tsx b/ui/components/espace_pro/Admin/utilisateurs/AdminUserForm.tsx new file mode 100644 index 0000000000..c29f25639d --- /dev/null +++ b/ui/components/espace_pro/Admin/utilisateurs/AdminUserForm.tsx @@ -0,0 +1,212 @@ +import { Box, Button, FormControl, FormLabel, HStack, Input, VStack, useToast } from "@chakra-ui/react" +import { FormikProvider, useFormik } from "formik" +import { getLastStatusEvent } from "shared" +import { AccessStatus, IRoleManagementEvent, IRoleManagementJson } from "shared/models/roleManagement.model" +import { IUser2Json } from "shared/models/user2.model" +import * as Yup from "yup" + +import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" +import { createAdminUser, updateEntrepriseAdmin } from "@/utils/api" +import { apiDelete } from "@/utils/api.utils" + +import CustomInput from "../../CustomInput" + +export const AdminUserForm = ({ + user, + role, + onCreate, + onDelete, + onUpdate, +}: { + user?: IUser2Json + role?: IRoleManagementJson + onCreate?: (result: void, error?: any) => void + onDelete?: () => void + onUpdate?: () => void +}) => { + const toast = useToast() + const { activate: activateUser, deactivate: deactivateUser } = useUserPermissionsActions(user?._id.toString()) + const formik = useFormik({ + initialValues: { + last_name: user?.last_name || "", + first_name: user?.first_name || "", + email: user?.email || "", + phone: user?.phone || "Non renseigné", + }, + validationSchema: Yup.object().shape({ + last_name: Yup.string().required("Votre nom est obligatoire"), + first_name: Yup.string().required("Votre prénom est obligatoire"), + email: Yup.string().email("Format d'email invalide").required("Votre email est obligatoire"), + phone: Yup.string(), + }), + enableReinitialize: true, + onSubmit: async (values, { setSubmitting }) => { + let result + let error + + try { + if (user) { + result = await updateEntrepriseAdmin(user._id.toString(), values) + if (result?.ok) { + toast({ + title: "Utilisateur mis à jour", + status: "success", + isClosable: true, + }) + } else { + toast({ + title: "Erreur lors de la mise à jour de l'utilisateur.", + status: "error", + isClosable: true, + description: " Merci de réessayer plus tard", + }) + } + onUpdate?.() + } else { + result = await createAdminUser(values).catch((err) => { + if (err.statusCode === 409) { + return { error: "Cet utilisateur existe déjà" } + } + }) + if (result?._id) { + toast({ + title: "Utilisateur créé", + status: "success", + isClosable: true, + }) + onCreate?.(result) + } else if (result?.error) { + error = toast({ + title: result.error, + status: "error", + isClosable: true, + }) + } else { + error = "Erreur lors de la création de l'utilisateur." + toast({ + title: error, + status: "error", + isClosable: true, + description: " Merci de réessayer plus tard", + }) + } + } + } catch (e) { + error = e + console.error(e) + const response = await (e?.json ?? {}) + const message = response?.message ?? e?.message + toast({ + title: message, + status: "error", + isClosable: true, + }) + } + setSubmitting(false) + }, + }) + const { values, dirty, handleSubmit } = formik + + const onDeleteClicked = async (event) => { + event.preventDefault() + if (confirm("Voulez-vous vraiment supprimer cet utilisateur ?")) { + const result = (await apiDelete("/admin/users/:userId", { params: { userId: user._id.toString() }, querystring: {} })) as any + if (result?.ok) { + toast({ + title: "Utilisateur supprimé", + status: "success", + isClosable: true, + }) + } else { + toast({ + title: "Erreur lors de la suppression de l'utilisateur.", + status: "error", + isClosable: true, + description: " Merci de réessayer plus tard", + }) + } + + return onDelete?.() + } + } + + const statusArray: IRoleManagementEvent[] = role?.status + const accessStatus = getLastStatusEvent(statusArray)?.status + + return ( + <> + {user && ( + <> + + Type de compte + ADMIN + + + Statut du compte = + + {accessStatus} + {accessStatus !== AccessStatus.GRANTED && ( + { + await activateUser() + onUpdate?.() + }} + /> + )} + {accessStatus !== AccessStatus.DENIED && ( + { + await deactivateUser("") + onUpdate?.() + }} + /> + )} + + + + + + + )} + + + + {user && ( + + Identifiant + + + )} + + + + + + + + + + + + ) +} + +const ActivateUserButton = ({ onClick }) => { + return ( + + ) +} + +const DisableUserButton = ({ onClick }) => { + return ( + + ) +} diff --git a/ui/components/espace_pro/Admin/utilisateurs/UserList.tsx b/ui/components/espace_pro/Admin/utilisateurs/AdminUserList.tsx similarity index 77% rename from ui/components/espace_pro/Admin/utilisateurs/UserList.tsx rename to ui/components/espace_pro/Admin/utilisateurs/AdminUserList.tsx index c7dfc5bfa6..2ce1ad51ad 100644 --- a/ui/components/espace_pro/Admin/utilisateurs/UserList.tsx +++ b/ui/components/espace_pro/Admin/utilisateurs/AdminUserList.tsx @@ -1,9 +1,8 @@ import { Box, Button, Flex, Modal, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay, useDisclosure } from "@chakra-ui/react" import dayjs from "dayjs" import { useQuery } from "react-query" -import { IUserRecruteur } from "shared" +import { IUser2 } from "shared/models/user2.model" -import { AUTHTYPE } from "@/common/contants" import { sortReactTableString } from "@/common/utils/dateUtils" import Link from "@/components/Link" import { ArrowRightLine2 } from "@/theme/components/icons" @@ -12,21 +11,20 @@ import { apiGet } from "../../../../utils/api.utils" import LoadingEmptySpace from "../../LoadingEmptySpace" import TableNew from "../../TableNew" -import UserForm from "./UserForm" +import { AdminUserForm } from "./AdminUserForm" -const UserList = () => { +const AdminUserList = () => { const newUser = useDisclosure() const { data: users, isLoading, refetch: refetchUsers, - } = useQuery({ + } = useQuery({ queryKey: ["adminusers"], queryFn: async () => { - const data = await apiGet("/admin/users", {}) - - return data.users + const users = await apiGet("/admin/users", {}) + return users.users }, }) @@ -48,9 +46,10 @@ const UserList = () => { fermer - { + { if (!error) { newUser.onClose() await refetchUsers() @@ -78,22 +77,10 @@ const UserList = () => { Cell: ({ value }) => value, filter: "fuzzyText", }, - { - Header: "Administrateur", - accessor: "type", - Cell: ({ value }) => (value === AUTHTYPE.ADMIN ? "Oui" : "Non"), - filter: "text", - }, - { - Header: "Scope", - id: "scope", - accessor: ({ scope }) => scope, - }, { Header: "Actif", id: "last_connection", - accessor: ({ last_connection }) => { - const date = last_connection + accessor: ({ last_action_date: date }) => { return date ? dayjs(date).format("DD/MM/YYYY") : "Jamais" }, }, @@ -119,4 +106,4 @@ const UserList = () => { ) } -export default UserList +export default AdminUserList diff --git a/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx b/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx deleted file mode 100644 index 7add3d05ac..0000000000 --- a/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { Box, Button, Checkbox, FormControl, FormErrorMessage, FormLabel, HStack, Input, VStack, useDisclosure, useToast } from "@chakra-ui/react" -import { useFormik } from "formik" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" -import * as Yup from "yup" - -import useUserHistoryUpdate from "@/common/hooks/useUserHistoryUpdate" -import { createUser } from "@/utils/api" -import { apiDelete, apiPut } from "@/utils/api.utils" - -import ConfirmationDesactivationUtilisateur from "../../ConfirmationDesactivationUtilisateur" - -const ActivateUserButton = ({ userId, onUpdate }) => { - const updateUserHistory = useUserHistoryUpdate(userId, ETAT_UTILISATEUR.VALIDE) - - return ( - - ) -} - -const DisableUserButton = ({ confirmationDesactivationUtilisateur }) => { - return ( - - ) -} - -const getActionButtons = (userHistory, userId, confirmationDesactivationUtilisateur, onUpdate) => { - switch (userHistory?.status) { - case ETAT_UTILISATEUR.ATTENTE: - return ( - <> - - - - ) - case ETAT_UTILISATEUR.VALIDE: - return - case ETAT_UTILISATEUR.DESACTIVE: - return - - default: - return <> - } -} - -const UserForm = ({ user, onCreate, onDelete, onUpdate }: { user: any; onCreate?: any; onDelete?: any; onUpdate?: any }) => { - const toast = useToast() - const confirmationDesactivationUtilisateur = useDisclosure() - const { values, errors, touched, dirty, handleSubmit, handleChange } = useFormik({ - initialValues: { - last_name: user?.last_name || "", - first_name: user?.first_name || "", - email: user?.email || "", - phone: user?.phone || "Non renseigné", - beAdmin: user?.type === "ADMIN" || false, - type: user?.type || "", - scope: user?.scope || "all", - establishment_siret: user?.establishment_siret || "13002526500013", - establishment_raison_sociale: user?.establishment_raison_sociale || "beta.gouv (Dinum)", - }, - validationSchema: Yup.object().shape({ - last_name: Yup.string().required("Votre nom est obligatoire"), - first_name: Yup.string().required("Votre prénom est obligatoire"), - email: Yup.string().email("Format d'email invalide").required("Votre email est obligatoire"), - phone: Yup.string(), - beAdmin: Yup.boolean().required("Vous devez cocher cette case"), - type: Yup.string(), - scope: Yup.string().required("obligatoire"), - establishment_siret: Yup.string().required("obligatoire"), - establishment_raison_sociale: Yup.string().required("obligatoire"), - }), - enableReinitialize: true, - onSubmit: async ({ beAdmin, ...values }, { setSubmitting }) => { - let result - let error - - try { - if (user) { - result = await apiPut("/admin/users/:userId", { - params: { userId: user._id }, - body: { - ...values, - type: beAdmin ? "ADMIN" : values.type, - }, - }) - if (result?.ok) { - toast({ - title: "Utilisateur mis à jour", - status: "success", - isClosable: true, - }) - } else { - toast({ - title: "Erreur lors de la mise à jour de l'utilisateur.", - status: "error", - isClosable: true, - description: " Merci de réessayer plus tard", - }) - } - onUpdate?.() - } else { - result = await createUser({ - ...values, - type: beAdmin ? "ADMIN" : values.type, - }).catch((err) => { - if (err.statusCode === 409) { - return { error: "Cet utilisateur existe déjà" } - } - }) - if (result?._id) { - toast({ - title: "Utilisateur créé", - status: "success", - isClosable: true, - }) - onCreate?.(result) - } else if (result?.error) { - error = toast({ - title: result.error, - status: "error", - isClosable: true, - }) - } else { - error = "Erreur lors de la création de l'utilisateur." - toast({ - title: error, - status: "error", - isClosable: true, - description: " Merci de réessayer plus tard", - }) - } - } - } catch (e) { - error = e - console.error(e) - const response = await (e?.json ?? {}) - const message = response?.message ?? e?.message - toast({ - title: message, - status: "error", - isClosable: true, - }) - } - setSubmitting(false) - }, - }) - - const onDeleteClicked = async (e) => { - e.preventDefault() - if (confirm("Voulez-vous vraiment supprimer cet utilisateur ?")) { - const result = (await apiDelete("/admin/users/:userId", { params: { userId: user._id as string }, querystring: {} })) as any - if (result?.ok) { - toast({ - title: "Utilisateur supprimé", - status: "success", - isClosable: true, - }) - } else { - toast({ - title: "Erreur lors de la suppression de l'utilisateur.", - status: "error", - isClosable: true, - description: " Merci de réessayer plus tard", - }) - } - - return onDelete?.() - } - } - - const [lastUserState] = user?.status.slice(-1) || "" - - return ( - <> - - {user && ( - <> - - Type de compte - {user.type} - - - Statut du compte = - - {lastUserState?.status} {getActionButtons(lastUserState, user._id, confirmationDesactivationUtilisateur, onUpdate)}{" "} - - - - - - - )} -
- - {user && ( - - Identifiant - - - )} - - Prénom - - {errors.first_name && touched.first_name && {errors.first_name as string}} - - - Nom - - {errors.last_name && touched.last_name && {errors.last_name as string}} - - - Email - - {errors.email && touched.email && {errors.email as string}} - - - Téléphone - - {errors.phone && touched.phone && {errors.phone as string}} - - - Siret - - {errors.establishment_siret && touched.establishment_siret && {errors.establishment_siret as string}} - - - Raison sociale - - {errors.establishment_raison_sociale && touched.establishment_raison_sociale && {errors.establishment_raison_sociale as string}} - - - scope - - {errors.scope && touched.scope && {errors.scope as string}} - - - type - - {errors.type && touched.type && {errors.type as string}} - - - - Administrateur - - {errors.beAdmin && touched.beAdmin && {errors.beAdmin as string}} - - - {user ? ( - - - - ) : ( - - )} - -
- - ) -} - -export default UserForm diff --git a/ui/components/espace_pro/Admin/utilisateurs/UserView.tsx b/ui/components/espace_pro/Admin/utilisateurs/UserView.tsx deleted file mode 100644 index 9ad484712a..0000000000 --- a/ui/components/espace_pro/Admin/utilisateurs/UserView.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Heading } from "@chakra-ui/react" -import { useRouter } from "next/router" -import { FC } from "react" -import { IUserRecruteurJson } from "shared" - -import { Breadcrumb } from "../../common/components/Breadcrumb" -import UserValidationHistory from "../../UserValidationHistory" - -import UserForm from "./UserForm" - -interface Props { - user: IUserRecruteurJson - refetchUser: any -} - -const UserView: FC = ({ user, refetchUser }) => { - const router = useRouter() - return ( - <> - - - Fiche utilisateur - - router.push("/espace-pro/admin/utilisateurs")} onUpdate={() => refetchUser()} /> - - - ) -} - -export default UserView diff --git a/ui/components/espace_pro/Admin/widgetParameters/constants/email.ts b/ui/components/espace_pro/Admin/widgetParameters/constants/email.ts deleted file mode 100644 index 1589b67c0f..0000000000 --- a/ui/components/espace_pro/Admin/widgetParameters/constants/email.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const emailStatus = { - request: "Envoyé", - delivered: "Délivré", - unique_opened: "1ère ouverture", - opened: "Connu ouvert", - proxy_open: "Chargé par proxy", - click: "Cliqué", - soft_bounce: "Soft Bounce", - hard_bounce: "Hard Bounce", - invalid_email: "Email invalide", - error: "Erreur", - deferred: "Différé", - spam: "Plainte", - unsubscribed: "Désinscrit", - blocked: "Bloqué", -} diff --git a/ui/components/espace_pro/AjouterVoeux.tsx b/ui/components/espace_pro/AjouterVoeux.tsx index 0a3e8fb116..039c9c4cac 100644 --- a/ui/components/espace_pro/AjouterVoeux.tsx +++ b/ui/components/espace_pro/AjouterVoeux.tsx @@ -1,8 +1,4 @@ import { - Accordion, - AccordionButton, - AccordionItem, - AccordionPanel, Box, Button, Checkbox, @@ -14,12 +10,10 @@ import { FormHelperText, FormLabel, Heading, - Image, Input, Link, Select, SimpleGrid, - Spinner, Stack, Text, Textarea, @@ -34,16 +28,16 @@ import { TRAINING_CONTRACT_TYPE, TRAINING_RYTHM } from "shared/constants/recrute import { JOB_STATUS } from "shared/models/job.model" import * as Yup from "yup" +import { AUTHTYPE } from "@/common/contants" +import { debounce } from "@/common/utils/debounce" +import { InfosDiffusionOffre } from "@/components/DepotOffre/InfosDiffusionOffre" +import { RomeDetailWithQuery } from "@/components/DepotOffre/RomeDetailWithQuery" +import { publicConfig } from "@/config.public" +import { LogoContext } from "@/context/contextLogo" +import { WidgetContext } from "@/context/contextWidget" import { useAuth } from "@/context/UserContext" - -import { AUTHTYPE } from "../../common/contants" -import { debounce } from "../../common/utils/debounce" -import { publicConfig } from "../../config.public" -import { LogoContext } from "../../context/contextLogo" -import { WidgetContext } from "../../context/contextWidget" -import { ArrowRightLine, ExternalLinkLine, InfoCircle, Minus, Plus, Warning } from "../../theme/components/icons" -import { J1S, Parcoursup } from "../../theme/components/logos_pro" -import { createOffre, createOffreByToken, getFormulaire, getFormulaireByToken, getRelatedEtablissementsFromRome, getRomeDetail } from "../../utils/api" +import { ArrowRightLine, ExternalLinkLine, Minus, Plus, Warning } from "@/theme/components/icons" +import { createOffre, createOffreByToken, getFormulaire, getFormulaireByToken, getRelatedEtablissementsFromRome } from "@/utils/api" import DropdownCombobox from "./DropdownCombobox" @@ -194,7 +188,6 @@ const AjouterVoeuxForm = (props) => { job_type: props.job_type ?? ["Apprentissage"], is_multi_published: props.is_multi_published ?? undefined, delegations: props.delegations ?? undefined, - rome_detail: props.rome_detail ?? {}, is_disabled_elligible: props.is_disabled_elligible ?? false, job_count: props.job_count ?? 1, job_duration: props.job_duration ?? 12, @@ -229,9 +222,8 @@ const AjouterVoeuxForm = (props) => { setFieldValue("rome_label", values.intitule) setFieldValue("rome_appellation_label", values.appellation) setFieldValue("rome_code", [values.codeRome]) + props.onSelectRome(values.codeRome, values.appellation) }, 0) - - props.getRomeInformation(values.codeRome, values.appellation, formik) }} name="rome_label" value={values.rome_appellation_label} @@ -371,142 +363,19 @@ const AjouterVoeuxForm = (props) => { ) } -const RomeInformationDetail = ({ definition, competencesDeBase, libelle, appellation, acces }) => { - if (definition) { - const definitionSplitted = definition.split("\\n") - const accesFormatted = acces.split("\\n").join("

") - - return ( - - - - {appellation} - - - Fiche métier : {libelle} - - La fiche métier se base sur la classification ROME de France Travail - - - - Voici la description visible par les candidats lors de la mise en ligne de l’offre d’emploi en alternance. - - - - - {({ isExpanded }) => ( - <> -

- - - Description du métier - - {isExpanded ? : } - -

- -
    - {definitionSplitted.map((x) => { - return ( -
  • - {x} -
  • - ) - })} -
-
- - )} -
-
- - {({ isExpanded }) => ( - <> -

- - - Quelles sont les compétences visées ? - - {isExpanded ? : } - -

- -
    - {competencesDeBase.map((x) => ( -
  • - {x.libelle} -
  • - ))} -
-
- - )} -
-
- - {({ isExpanded }) => ( - <> -

- - - À qui ce métier est-il accessible ? - - {isExpanded ? : } - -

- - - - - )} -
-
-
- ) - } else { - return ( - - - Dites-nous en plus sur votre besoin en recrutement - - - - Cela permettra à votre offre d'être visible des candidats intéressés. - - - Une fois créée, votre offre d’emploi sera immédiatement mise en ligne sur les sites suivants : - - - - - - - - ) - } -} - export const PageAjouterVoeux = (props) => { - const [romeInformation, setRomeInformation] = useState({}) - const [loading, setLoading] = useState(false) + const { rome_appellation_label, rome_code } = props + const rome = rome_code?.at(0) + const [romeAndAppellation, setRomeAndAppellation] = useState<{ rome: string; appellation: any }>( + rome_appellation_label && rome ? { rome, appellation: rome_appellation_label } : null + ) const { widget } = useContext(WidgetContext) const router = useRouter() const { establishment_id } = router.query - const getRomeInformation = (rome: string, appellation, formik) => { - getRomeDetail(rome) - .then((result) => { - setLoading(true) - formik.setFieldValue("rome_detail", result.data) - setRomeInformation({ appellation, ...result.data }) - }) - .catch((error) => console.error(error)) - .finally(() => { - setTimeout(() => { - setLoading(false) - }, 700) - }) + const onSelectRome = (rome: string, appellation: any) => { + setRomeAndAppellation({ rome, appellation }) } if (!establishment_id) return <> @@ -524,19 +393,10 @@ export const PageAjouterVoeux = (props) => { Merci de renseigner les champs ci-dessous pour créer votre offre - +
- - {loading ? ( - - - Recherche en cours... - - ) : ( - - )} - + {romeAndAppellation ? : } ) } diff --git a/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx b/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx index 8d1d482ae8..a0c735b6ab 100644 --- a/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx +++ b/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx @@ -2,8 +2,8 @@ import { Box, Button, Flex, FormControl, FormErrorMessage, FormHelperText, FormL import { Form, Formik } from "formik" import { useRouter } from "next/router" import { useContext, useState } from "react" -import { IUserStatusValidationJson } from "shared" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { IRecruiterJson, assertUnreachable } from "shared" +import { IUser2Json } from "shared/models/user2.model" import * as Yup from "yup" import { ApiError } from "@/utils/api.utils" @@ -151,7 +151,7 @@ const FormulaireLayout = ({ left, right }) => { export const InformationCreationCompte = ({ isWidget = false }: { isWidget?: boolean }) => { const router = useRouter() const validationPopup = useDisclosure() - const [popupData, setPopupData] = useState({}) + const [popupData, setPopupData] = useState<{ user: IUser2Json; formulaire: IRecruiterJson; token?: string; type: "CFA" | "ENTREPRISE" } | null>(null) const { type, informationSiret: informationSiretString }: { type: "CFA" | "ENTREPRISE"; informationSiret: string } = router.query as any const informationSiret = JSON.parse(informationSiretString || "{}") @@ -159,7 +159,7 @@ export const InformationCreationCompte = ({ isWidget = false }: { isWidget?: boo const submitForm = (values, { setSubmitting, setFieldError }) => { const payload = { ...values, type, establishment_siret } - if (type === "CFA") { + if (type === AUTHTYPE.CFA) { delete payload.opco } createEtablissement(payload) @@ -167,31 +167,35 @@ export const InformationCreationCompte = ({ isWidget = false }: { isWidget?: boo if (!data) { throw new Error("no data") } - const statusArray: IUserStatusValidationJson[] = data.user?.status ?? [] - if (statusArray?.at(0)?.status === ETAT_UTILISATEUR.VALIDE) { - if (data.user.type === AUTHTYPE.ENTREPRISE) { + const isValidated = data.validated + if (isValidated) { + if (type === AUTHTYPE.ENTREPRISE) { // Dépot simplifié router.push({ pathname: isWidget ? "/espace-pro/widget/entreprise/offre" : "/espace-pro/creation/offre", query: { establishment_id: data.formulaire.establishment_id, type, email: data.user.email, userId: data.user._id.toString(), token: data.token }, }) - } else { + } else if (type === AUTHTYPE.CFA) { router.push({ pathname: "/espace-pro/authentification/confirmation", query: { email: data.user.email }, }) + } else { + assertUnreachable(type) } } else { validationPopup.onOpen() - setPopupData({ ...data, type }) + const { user, formulaire } = data + if (!user) { + throw new Error("unexpected: data.user is empty") + } + setPopupData({ user, formulaire, ...data, type }) } setSubmitting(false) }) .catch((error) => { - console.error(error) if (error instanceof ApiError) { - const payload: { error: string; statusCode: number; message: string } = error.context.errorData - setFieldError("email", payload.message) + setFieldError("email", error.message) setSubmitting(false) } }) diff --git a/ui/components/espace_pro/ConfirmationActivationUtilsateur.tsx b/ui/components/espace_pro/ConfirmationActivationUtilsateur.tsx index 90ccb3732c..d0b2be5c53 100644 --- a/ui/components/espace_pro/ConfirmationActivationUtilsateur.tsx +++ b/ui/components/espace_pro/ConfirmationActivationUtilsateur.tsx @@ -1,15 +1,15 @@ import { Button, Heading, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Text } from "@chakra-ui/react" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" -import useUserHistoryUpdate from "../../common/hooks/useUserHistoryUpdate" +import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" + import { Close } from "../../theme/components/icons" export const ConfirmationActivationUtilsateur = (props) => { const { isOpen, onClose, establishment_raison_social, _id } = props - const updateUserHistory = useUserHistoryUpdate(_id, ETAT_UTILISATEUR.VALIDE) + const { activate } = useUserPermissionsActions(_id) const activateUser = async () => { - await updateUserHistory() + await activate() onClose() } diff --git a/ui/components/espace_pro/ConfirmationCreationCompte.tsx b/ui/components/espace_pro/ConfirmationCreationCompte.tsx index 9ec24488a1..acbbb7cb48 100644 --- a/ui/components/espace_pro/ConfirmationCreationCompte.tsx +++ b/ui/components/espace_pro/ConfirmationCreationCompte.tsx @@ -1,6 +1,8 @@ import { Box, Button, Center, Heading, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Stack, Text } from "@chakra-ui/react" import { useRouter } from "next/router" import { useContext } from "react" +import { IRecruiterJson } from "shared" +import { IUser2Json } from "shared/models/user2.model" import { AUTHTYPE } from "../../common/contants" import { redirect } from "../../common/utils/router" @@ -8,17 +10,25 @@ import { WidgetContext } from "../../context/contextWidget" import { InfoCircle } from "../../theme/components/icons" import { deleteCfa, deleteEntreprise } from "../../utils/api" -export const ConfirmationCreationCompte = (props) => { - const { isOpen, onClose, user, formulaire, isWidget, token } = props +export const ConfirmationCreationCompte = (props: { + isOpen: boolean + onClose: () => void + user: IUser2Json + formulaire: IRecruiterJson + isWidget: boolean + type: "ENTREPRISE" | "CFA" + token?: string +}) => { + const { isOpen, onClose, user, formulaire, isWidget, token, type } = props const router = useRouter() const { widget } = useContext(WidgetContext) const validateAccountCreation = () => { - switch (user.type) { + switch (type) { case AUTHTYPE.ENTREPRISE: router.push({ pathname: isWidget ? "/espace-pro/widget/entreprise/offre" : "/espace-pro/creation/offre", - query: { establishment_id: formulaire.establishment_id, email: user.email, type: user.type, userId: user._id, displayBanner: true, token }, + query: { establishment_id: formulaire.establishment_id, email: user.email, type, userId: user._id.toString(), displayBanner: true, token }, }) break case AUTHTYPE.CFA: @@ -32,8 +42,8 @@ export const ConfirmationCreationCompte = (props) => { } const deleteAccount = async () => { - if (user.type === AUTHTYPE.ENTREPRISE) { - await deleteEntreprise(user._id, formulaire._id) + if (type === AUTHTYPE.ENTREPRISE) { + await deleteEntreprise(user._id.toString(), formulaire._id.toString()) } else { await deleteCfa(user._id) } diff --git a/ui/components/espace_pro/ConfirmationDesactivationUtilisateur.tsx b/ui/components/espace_pro/ConfirmationDesactivationUtilisateur.tsx index fac653b425..01da56bbd7 100644 --- a/ui/components/espace_pro/ConfirmationDesactivationUtilisateur.tsx +++ b/ui/components/espace_pro/ConfirmationDesactivationUtilisateur.tsx @@ -16,10 +16,10 @@ import { } from "@chakra-ui/react" import { useState } from "react" import { IUserRecruteurJson } from "shared" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" + +import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" import { AUTHTYPE } from "../../common/contants" -import useUserHistoryUpdate from "../../common/hooks/useUserHistoryUpdate" import { Close } from "../../theme/components/icons" import { archiveDelegatedFormulaire, archiveFormulaire, updateEntrepriseAdmin } from "../../utils/api" @@ -33,8 +33,7 @@ export const ConfirmationDesactivationUtilisateur = ({ const _id = (_idObject ?? "").toString() const [reason, setReason] = useState() const reasonComment = useDisclosure() - const disableUser = useUserHistoryUpdate(_id, ETAT_UTILISATEUR.DESACTIVE, reason) - const reassignUserToAdmin = useUserHistoryUpdate(_id, ETAT_UTILISATEUR.ATTENTE, reason) + const { deactivate: disableUser, waitsForValidation: reassignUserToAdmin } = useUserPermissionsActions(_id) if (!userRecruteur) return null @@ -51,17 +50,17 @@ export const ConfirmationDesactivationUtilisateur = ({ switch (type) { case AUTHTYPE.ENTREPRISE: if (reason === "Ne relève pas des champs de compétences de mon OPCO") { - await Promise.all([updateEntrepriseAdmin(_id, establishment_id, { opco: "inconnu" }), reassignUserToAdmin()]) + await Promise.all([updateEntrepriseAdmin(_id, { opco: "inconnu" }, establishment_siret), reassignUserToAdmin(reason)]) } else { - await Promise.all([archiveFormulaire(establishment_id), disableUser()]) + await Promise.all([archiveFormulaire(establishment_id), disableUser(reason)]) } break case AUTHTYPE.CFA: - await Promise.all([archiveDelegatedFormulaire(establishment_siret), disableUser()]) + await Promise.all([archiveDelegatedFormulaire(establishment_siret), disableUser(reason)]) break case AUTHTYPE.ADMIN: - await disableUser() + await disableUser(reason) break default: throw new Error(`unsupported type: ${type}`) diff --git a/ui/components/espace_pro/ListeOffres.tsx b/ui/components/espace_pro/ListeOffres.tsx index fe892b6dc3..b3663a0445 100644 --- a/ui/components/espace_pro/ListeOffres.tsx +++ b/ui/components/espace_pro/ListeOffres.tsx @@ -70,7 +70,7 @@ const NumberCell = ({ children }) => { ) } -export default function ListeOffres() { +export default function ListeOffres({ hideModify = false }: { hideModify?: boolean }) { const router = useRouter() const confirmationSuppression = useDisclosure() const [currentOffre, setCurrentOffre] = useState() @@ -120,7 +120,7 @@ export default function ListeOffres() { {entrepriseTitle} - {user.type !== AUTHTYPE.OPCO && ( + {!hideModify && user.type !== AUTHTYPE.OPCO && ( @@ -322,7 +322,7 @@ export default function ListeOffres() { {establishment_raison_sociale ?? `SIRET ${establishment_siret}`} - {user.type !== AUTHTYPE.OPCO && ( + {!hideModify && user.type !== AUTHTYPE.OPCO && ( diff --git a/ui/components/espace_pro/withAuth.tsx b/ui/components/espace_pro/withAuth.tsx index 682a73f5c0..156a23afbd 100644 --- a/ui/components/espace_pro/withAuth.tsx +++ b/ui/components/espace_pro/withAuth.tsx @@ -37,8 +37,9 @@ export const withAuth = (Component, scope = null) => { export const authProvider = (Component) => { const Wrapper = (props) => { + const { user, userAccess } = props return ( - + ) diff --git a/ui/components/footer.tsx b/ui/components/footer.tsx index 61257b0b3a..7e48891885 100644 --- a/ui/components/footer.tsx +++ b/ui/components/footer.tsx @@ -1,7 +1,6 @@ import { ExternalLinkIcon } from "@chakra-ui/icons" import { Box, Divider, Flex, Grid, GridItem, Image, Link, ListItem, UnorderedList } from "@chakra-ui/react" import NextLink from "next/link" -import React from "react" import { publicConfig } from "../config.public" @@ -116,7 +115,14 @@ const Footer = ({ ressources = "" }: { ressources?: string }) => { - Ressources + Ressources + + + + + + Blog + diff --git a/ui/context/UserContext.tsx b/ui/context/UserContext.tsx index d920de9ac1..abe244af8c 100644 --- a/ui/context/UserContext.tsx +++ b/ui/context/UserContext.tsx @@ -1,12 +1,13 @@ import { Spinner } from "@chakra-ui/react" -import React, { useState, useEffect, createContext, FC, PropsWithChildren, useContext } from "react" -import { IUserRecruteurPublic } from "shared" +import React, { createContext, useContext, useEffect, useState } from "react" +import { ComputedUserAccess, IUserRecruteurPublic } from "shared" import { emitter } from "@/common/utils/emitter" import { apiGet } from "@/utils/api.utils" interface IAuthContext { user?: IUserRecruteurPublic + userAccess?: ComputedUserAccess setUser: (user?: IUserRecruteurPublic) => void } @@ -15,10 +16,6 @@ export const AuthContext = createContext({ setUser: () => {}, }) -interface Props extends PropsWithChildren { - initialUser?: IUserRecruteurPublic -} - export async function getSession(): Promise { try { const session: IUserRecruteurPublic = await apiGet(`/auth/session`, {}) @@ -28,14 +25,32 @@ export async function getSession(): Promise { } } -export const UserContext: FC = ({ children, initialUser }) => { +export async function getUserAccess() { + try { + const userAccess: ComputedUserAccess = await apiGet(`/auth/access`, {}) + return userAccess + } catch (error) { + return null + } +} + +export const UserContext = ({ + children, + user: initialUser, + userAccess: initialUserAccess, +}: Pick & { + children: React.ReactNode +}) => { const [user, setUser] = useState(initialUser) + const [userAccess, setUserAccess] = useState(initialUserAccess) const [isLoading, setIsLoading] = useState(!initialUser) useEffect(() => { async function getUser() { const user = initialUser ?? (await getSession()) + const userAccess = await getUserAccess() setUser(user) + setUserAccess(userAccess) setIsLoading(false) } if (!initialUser) { @@ -49,6 +64,7 @@ export const UserContext: FC = ({ children, initialUser }) => { if (response.status === 401) { //Auto logout user when token is invalid setUser(null) + setUserAccess(null) } } emitter.on("http:error", handler) @@ -59,7 +75,7 @@ export const UserContext: FC = ({ children, initialUser }) => { return } - return {children} + return {children} } export const useAuth = () => useContext(AuthContext) diff --git a/ui/pages/espace-pro/admin/utilisateurs/[userId]/index.tsx b/ui/pages/espace-pro/admin/utilisateurs/[userId]/index.tsx index 50aaa9b8ec..d0fbd77611 100644 --- a/ui/pages/espace-pro/admin/utilisateurs/[userId]/index.tsx +++ b/ui/pages/espace-pro/admin/utilisateurs/[userId]/index.tsx @@ -1,36 +1,31 @@ import { useRouter } from "next/router" import { useQuery } from "react-query" -import { IUserRecruteurJson } from "shared" import { getAuthServerSideProps } from "@/common/SSR/getAuthServerSideProps" import { Layout, LoadingEmptySpace } from "@/components/espace_pro" -import UserView from "@/components/espace_pro/Admin/utilisateurs/UserView" +import { AdminUserForm } from "@/components/espace_pro/Admin/utilisateurs/AdminUserForm" import { authProvider, withAuth } from "@/components/espace_pro/withAuth" import { apiGet } from "@/utils/api.utils" -interface Props { - params: { userId: string } -} - -const AdminUserView = ({ params }: Props) => { +const AdminUserView = ({ userId }: { userId: string }) => { const { data: user, isLoading, refetch: refetchUser, - } = useQuery({ + } = useQuery({ queryKey: ["adminusersview"], queryFn: async () => { - const user = await apiGet("/admin/users/:userId", { params }) + const user = await apiGet("/admin/users/:userId", { params: { userId } }) return user }, - enabled: !!params.userId, + enabled: !!userId, }) - if (isLoading || !params.userId) { + if (isLoading || !userId) { return } - return + return } function AdminUserViewPage() { @@ -39,7 +34,7 @@ function AdminUserViewPage() { return ( - + ) } diff --git a/ui/pages/espace-pro/admin/utilisateurs/index.tsx b/ui/pages/espace-pro/admin/utilisateurs/index.tsx index 496e82f630..41843aa870 100644 --- a/ui/pages/espace-pro/admin/utilisateurs/index.tsx +++ b/ui/pages/espace-pro/admin/utilisateurs/index.tsx @@ -2,27 +2,21 @@ import { Box, Heading } from "@chakra-ui/react" import { getAuthServerSideProps } from "@/common/SSR/getAuthServerSideProps" import { Layout } from "@/components/espace_pro" -import UserList from "@/components/espace_pro/Admin/utilisateurs/UserList" +import AdminUserList from "@/components/espace_pro/Admin/utilisateurs/AdminUserList" import { Breadcrumb } from "@/components/espace_pro/common/components/Breadcrumb" import { authProvider, withAuth } from "@/components/espace_pro/withAuth" -const AdminUsers = () => { - const title = "Gestion des administrateurs" - return ( - - - - {title} - - - - ) -} - function AdminUsersPage() { + const title = "Gestion des administrateurs" return ( - + + + + {title} + + + ) } diff --git a/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx b/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx index 2c65987776..3b76ec1927 100644 --- a/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx +++ b/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx @@ -35,7 +35,7 @@ const Formulaire = ({ * KBA 20230511 : values for recruiter collection are casted in api.js file directly. form values must remain as awaited in userRecruteur collection */ // TODO - const entrepriseMutation = useMutation(({ userId, establishment_id, values }) => updateEntreprise(userId, establishment_id, values), { + const entrepriseMutation = useMutation(({ userId, values }) => updateEntreprise(userId, values), { onSuccess: () => { toast({ title: "Entreprise mise à jour avec succès.", @@ -67,7 +67,7 @@ const Formulaire = ({ const submitForm = async (values, { setSubmitting, setFieldError }) => { if (user.type === AUTHTYPE.ENTREPRISE) { entrepriseMutation.mutate( - { userId: user._id, establishment_id: establishment_id, values }, + { userId: user._id, values }, { onError: (error: any) => { switch (error.response.data.reason) { @@ -160,6 +160,7 @@ function EditionEntrepriseContact() { const { user } = useAuth() const { establishment_id } = router.query as { establishment_id: string } + // TODO pourquoi afficher le formulaire ? const { data, isLoading } = useQuery("formulaire-edition", () => getFormulaire(establishment_id), { cacheTime: 0, enabled: !!establishment_id }) if (isLoading || !establishment_id) { diff --git a/ui/pages/espace-pro/administration/index.tsx b/ui/pages/espace-pro/administration/index.tsx index 300f9afc0b..38ec8f3a93 100644 --- a/ui/pages/espace-pro/administration/index.tsx +++ b/ui/pages/espace-pro/administration/index.tsx @@ -57,7 +57,7 @@ function ListeEntreprise() { const [currentEntreprise, setCurrentEntreprise] = useState() const confirmationSuppression = useDisclosure() const router = useRouter() - const { user } = useAuth() + const { userAccess } = useAuth() const toast = useToast() useEffect(() => { @@ -73,7 +73,9 @@ function ListeEntreprise() { } }, []) - const { data, isLoading } = useQuery("listeEntreprise", () => getEntreprisesManagedByCfa(user._id.toString())) + const cfaId = userAccess?.cfas.at(0) + + const { data, isLoading } = useQuery("listeEntreprise", () => getEntreprisesManagedByCfa(cfaId), { enabled: Boolean(cfaId) }) if (isLoading) { return diff --git a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/[establishment_id]/index.tsx b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/[establishment_id]/index.tsx index 0c36f4239d..339992c29c 100644 --- a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/[establishment_id]/index.tsx +++ b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/[establishment_id]/index.tsx @@ -7,7 +7,7 @@ import { authProvider, withAuth } from "../../../../../../../components/espace_p function OpcoEntrepriseListOffre() { return ( - + ) } diff --git a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/entreprise/[entreprise_id].tsx similarity index 90% rename from ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx rename to ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/entreprise/[entreprise_id].tsx index 501072db97..be51611e2d 100644 --- a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx +++ b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/entreprise/[entreprise_id].tsx @@ -25,11 +25,9 @@ import { IUserStatusValidation } from "shared" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" import * as Yup from "yup" +import { AUTHTYPE } from "@/common/contants" +import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" import { getAuthServerSideProps } from "@/common/SSR/getAuthServerSideProps" -import { useAuth } from "@/context/UserContext" - -import { AUTHTYPE } from "../../../../../../common/contants" -import useUserHistoryUpdate from "../../../../../../common/hooks/useUserHistoryUpdate" import { AnimationContainer, ConfirmationDesactivationUtilisateur, @@ -39,11 +37,12 @@ import { Layout, LoadingEmptySpace, UserValidationHistory, -} from "../../../../../../components/espace_pro" -import { OpcoSelect } from "../../../../../../components/espace_pro/CreationRecruteur/OpcoSelect" -import { authProvider, withAuth } from "../../../../../../components/espace_pro/withAuth" -import { ArrowDropRightLine, ArrowRightLine } from "../../../../../../theme/components/icons" -import { getUser, updateEntrepriseAdmin } from "../../../../../../utils/api" +} from "@/components/espace_pro" +import { OpcoSelect } from "@/components/espace_pro/CreationRecruteur/OpcoSelect" +import { authProvider, withAuth } from "@/components/espace_pro/withAuth" +import { useAuth } from "@/context/UserContext" +import { ArrowDropRightLine, ArrowRightLine } from "@/theme/components/icons" +import { getUser, updateEntrepriseAdmin } from "@/utils/api" function DetailEntreprise() { const confirmationDesactivationUtilisateur = useDisclosure() @@ -52,25 +51,25 @@ function DetailEntreprise() { const toast = useToast() const { user } = useAuth() const router = useRouter() - const { siret_userId } = router.query as { siret_userId: string } + const { siret_userId, entreprise_id } = router.query as { siret_userId: string; entreprise_id: string } const { data: userRecruteur, isLoading } = useQuery("user", { enabled: !!siret_userId, - queryFn: () => getUser(siret_userId), + queryFn: () => getUser(siret_userId, entreprise_id), cacheTime: 0, }) - const userMutation = useMutation(({ userId, establishment_id, values }: any) => updateEntrepriseAdmin(userId, establishment_id, values), { + const userMutation = useMutation(({ userId, values }: any) => updateEntrepriseAdmin(userId, values, userRecruteur.establishment_siret), { onSuccess: () => { client.invalidateQueries("user") }, }) const ActivateUserButton = ({ userId }) => { - const updateUserHistory = useUserHistoryUpdate(userId, ETAT_UTILISATEUR.VALIDE) + const { activate } = useUserPermissionsActions(userId) return ( - ) @@ -211,7 +210,7 @@ function DetailEntreprise() { onSubmit={async (values, { setSubmitting }) => { setSubmitting(true) // For companies we update the User Collection and the Formulaire collection at the same time - userMutation.mutate({ userId: userRecruteur._id, establishment_id: userRecruteur.establishment_id, values }) + userMutation.mutate({ userId: userRecruteur._id, values }) toast({ title: "Mise à jour enregistrée avec succès", position: "top-right", diff --git a/ui/pages/espace-pro/administration/opco/index.tsx b/ui/pages/espace-pro/administration/opco/index.tsx index d1b8820893..695c1ff4d0 100644 --- a/ui/pages/espace-pro/administration/opco/index.tsx +++ b/ui/pages/espace-pro/administration/opco/index.tsx @@ -73,10 +73,10 @@ function AdministrationOpco() { row: { id }, }, }) => { - const { establishment_raison_sociale, establishment_siret, _id } = data[id] + const { establishment_raison_sociale, establishment_siret, _id, organizationId } = data[id] return ( - + {establishment_raison_sociale} @@ -162,7 +162,7 @@ function AdministrationOpco() { - Voir les informations + Voir les informations Voir les offres diff --git a/ui/pages/espace-pro/administration/users/[userId].tsx b/ui/pages/espace-pro/administration/users/[userId].tsx index afcf619c77..eb0addf750 100644 --- a/ui/pages/espace-pro/administration/users/[userId].tsx +++ b/ui/pages/espace-pro/administration/users/[userId].tsx @@ -25,11 +25,11 @@ import { IUserStatusValidation } from "shared" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" import * as Yup from "yup" +import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" import { getAuthServerSideProps } from "@/common/SSR/getAuthServerSideProps" import { useAuth } from "@/context/UserContext" import { AUTHTYPE } from "../../../../common/contants" -import useUserHistoryUpdate from "../../../../common/hooks/useUserHistoryUpdate" import { AnimationContainer, ConfirmationDesactivationUtilisateur, @@ -56,10 +56,10 @@ function DetailEntreprise() { const { user } = useAuth() const ActivateUserButton = ({ userId }) => { - const updateUserHistory = useUserHistoryUpdate(userId, ETAT_UTILISATEUR.VALIDE) + const { activate } = useUserPermissionsActions(userId) return ( - ) @@ -120,7 +120,7 @@ function DetailEntreprise() { const { data: userRecruteur, isLoading } = useQuery("user", () => getUser(userId), { cacheTime: 0, enabled: !!userId }) // @ts-expect-error: TODO - const userMutation = useMutation(({ userId, establishment_id, values }) => updateEntrepriseAdmin(userId, establishment_id, values), { + const userMutation = useMutation(({ userId, values }) => updateEntrepriseAdmin(userId, values, userRecruteur.establishment_siret), { onSuccess: () => { client.invalidateQueries("user") }, @@ -209,7 +209,7 @@ function DetailEntreprise() { setSubmitting(true) // For companies we update the User Collection and the Formulaire collection at the same time // @ts-expect-error: TODO - userMutation.mutate({ userId: userRecruteur._id, establishment_id: userRecruteur.establishment_id, values }) + userMutation.mutate({ userId: userRecruteur._id, values }) toast({ title: "Mise à jour enregistrée avec succès", position: "top-right", diff --git a/ui/pages/espace-pro/administration/users/index.tsx b/ui/pages/espace-pro/administration/users/index.tsx index 78c7f07a05..a9a5c8c90a 100644 --- a/ui/pages/espace-pro/administration/users/index.tsx +++ b/ui/pages/espace-pro/administration/users/index.tsx @@ -22,6 +22,7 @@ import dayjs from "dayjs" import { useRouter } from "next/router" import { useEffect, useState } from "react" import { useQuery } from "react-query" +import { getLastStatusEvent, IUserRecruteur } from "shared" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" import { getAuthServerSideProps } from "@/common/SSR/getAuthServerSideProps" @@ -54,7 +55,7 @@ function Users() { } }, []) - const { isLoading, data } = useQuery("user-list", () => apiGet(`/user`, {})) + const { isLoading, data } = useQuery("user-list", () => apiGet("/user", {})) if (isLoading) { return @@ -165,10 +166,8 @@ function Users() { id: "action", maxWidth: "80", disableSortBy: true, - accessor: (row) => { - const { status: statusArray = [] } = row - const lastStatus = statusArray[statusArray.length - 1] - const { status } = lastStatus + accessor: (row: IUserRecruteur) => { + const status = getLastStatusEvent(row.status)?.status const canActivate = [ETAT_UTILISATEUR.DESACTIVE, ETAT_UTILISATEUR.ATTENTE].includes(status) const canDeactivate = [ETAT_UTILISATEUR.VALIDE, ETAT_UTILISATEUR.ATTENTE].includes(status) return ( @@ -235,7 +234,7 @@ function Users() { En attente de vérification ({data.awaiting.length}) - Actifs ({data.active.length}) + Actifs Désactivés ({data.disabled.length}) En erreur ({data.error.length}) diff --git a/ui/pages/espace-pro/authentification/index.tsx b/ui/pages/espace-pro/authentification/index.tsx index f6b916a520..b176f554d6 100644 --- a/ui/pages/espace-pro/authentification/index.tsx +++ b/ui/pages/espace-pro/authentification/index.tsx @@ -53,7 +53,7 @@ const ConnexionCompte = () => { Vous avez déjà un compte ? - Veuillez indiquer ci-dessous le mail avec lequel vous avez créé votre compte afin de recevoir le lien de connexion à votre espace. + Indiquez le mail avec lequel vous avez créé votre compte et vous recevrez un lien pour vous connecter. ( +const EmailEnValidationManuelle = () => ( Merci ! Votre adresse email est bien confirmée. @@ -21,7 +21,7 @@ const EmailValide = () => ( ) -const EmailInvalide = () => ( +const ErreurValidation = () => ( Mail invalide @@ -37,8 +37,7 @@ const EmailInvalide = () => ( export default function ConfirmationValidationEmail() { const [loading, setLoading] = useBoolean() - const [isInvalid, setIsInvalid] = useBoolean() - const [isAwaitingValidation, setIsAwaitingValidation] = useBoolean() + const [validationState, setValidationState] = useState<"Attente" | "Error" | null>(null) const router = useRouter() const { token } = router.query as { token: string } @@ -54,35 +53,28 @@ export default function ConfirmationValidationEmail() { }, }) if (user.status_current === ETAT_UTILISATEUR.ATTENTE) { - setLoading.off() - setIsInvalid.off() - setIsAwaitingValidation.on() + setValidationState("Attente") setTimeout(() => router.push("/"), 10000) } else { - setLoading.off() setUser(user) router.push("/espace-pro/authentification/validation") } } } - fetchData().catch(() => { - setLoading.off() - setIsInvalid.on() - }) + fetchData() + .catch(() => { + setValidationState("Error") + }) + .finally(() => { + setLoading.off() + }) }, [token]) return ( <> {loading && } - {isAwaitingValidation && ( - - - - )} - {isInvalid && ( - - - + {validationState && ( + {validationState === "Attente" ? : validationState === "Error" ? : null} )} ) diff --git a/ui/pages/espace-pro/compte.tsx b/ui/pages/espace-pro/compte.tsx index fc4096f73a..f80d2861d2 100644 --- a/ui/pages/espace-pro/compte.tsx +++ b/ui/pages/espace-pro/compte.tsx @@ -17,7 +17,7 @@ import Layout from "../../components/espace_pro/Layout" import ModificationCompteEmail from "../../components/espace_pro/ModificationCompteEmail" import { authProvider, withAuth } from "../../components/espace_pro/withAuth" import { ArrowDropRightLine, ArrowRightLine } from "../../theme/components/icons" -import { getUser, updateEntreprise } from "../../utils/api" +import { getUser } from "../../utils/api" function Compte() { const client = useQueryClient() @@ -43,36 +43,16 @@ function Compte() { } const { data, isLoading } = useQuery("user", () => getUser(user._id.toString())) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const userMutation = useMutation(({ userId, establishment_id, values, email, setFieldError }: any) => updateEntreprise(userId, establishment_id, values), { - onSuccess: (_, variables) => { - client.invalidateQueries("user") - toast({ - title: "Mise à jour enregistrée avec succès", - position: "top-right", - status: "success", - duration: 2000, - isClosable: true, - }) - - if (variables.email !== variables.values.email) { - ModificationEmailPopup.onOpen() - } - }, - onError: (_, variables) => { - variables.setFieldError("email", "L'adresse mail est déjà associée à un compte La bonne alternance.") - }, - }) - - const partenaireMutation = useMutation( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ({ userId, values, email, setFieldError }: any) => - apiPut("/etablissement/:id", { + const userMutation = useMutation( + ({ values }: { values: any; isChangingEmail: boolean; setFieldError: any }) => { + const userId = user._id.toString() + return apiPut("/etablissement/:id", { params: { id: userId, }, - body: { ...values, _id: user._id.toString() }, - }), + body: { ...values, _id: userId }, + }) + }, { onSuccess: (_, variables) => { client.invalidateQueries("user") @@ -84,12 +64,12 @@ function Compte() { isClosable: true, }) - if (variables.email !== variables.values.email) { + if (variables.isChangingEmail) { ModificationEmailPopup.onOpen() } }, - onError: (error: any, variables) => { - variables.setFieldError("email", error.message) + onError: (_, variables) => { + variables.setFieldError("email", "L'adresse mail est déjà associée à un compte La bonne alternance.") }, } ) @@ -137,11 +117,8 @@ function Compte() { })} onSubmit={async (values, { setSubmitting, setFieldError }) => { setSubmitting(true) - if (user.type === AUTHTYPE.ENTREPRISE) { - userMutation.mutate({ userId: data._id, establishment_id: user.establishment_id, values, email: data.email, setFieldError }) - } else { - partenaireMutation.mutate({ userId: data._id, values, email: data.email, setFieldError }) - } + const isChangingEmail = data.email !== values.email + userMutation.mutate({ values, isChangingEmail, setFieldError }) setSubmitting(false) }} > diff --git a/ui/pages/espace-pro/creation/fin.tsx b/ui/pages/espace-pro/creation/fin.tsx index 7802746784..1ddaa4335b 100644 --- a/ui/pages/espace-pro/creation/fin.tsx +++ b/ui/pages/espace-pro/creation/fin.tsx @@ -95,7 +95,7 @@ function FinComponent(props: ComponentProps) { } const resendMail = () => { - sendValidationLink(userId.toString()) + sendValidationLink(userId.toString(), token) .then(() => { toast({ title: "Email envoyé.", diff --git a/ui/services/fetchFtJobDetails.ts b/ui/services/fetchFtJobDetails.ts index 8cdafa5193..83b665c077 100644 --- a/ui/services/fetchFtJobDetails.ts +++ b/ui/services/fetchFtJobDetails.ts @@ -1,13 +1,21 @@ -import axios from "axios" +import { ILbaItemFtJob, zRoutes } from "@/../shared" +import { z } from "zod" -import { offreApi } from "@/components/SearchForTrainingsAndJobs/services/utils" +import { apiGet } from "@/utils/api.utils" -export default async function fetchFtJobDetails(job) { - const res = null - if (!job) { - return res +const zodSchema = zRoutes.get["/v1/jobs/job/:id"].response["200"] + +const fetchFtJobDetails = async (job: { id: string }): Promise => { + const response = await apiGet("/v1/jobs/job/:id", { params: { id: job.id }, querystring: {} }) + + const typedResponse = response as z.output + const [firstFtJob] = typedResponse?.peJobs ?? [] + if (firstFtJob) { + firstFtJob.detailsLoaded = true + return firstFtJob + } else { + throw new Error("unexpected_error") } - const ftJobApi = `${offreApi}/${encodeURIComponent(job.id)}` - const response = await axios.get(ftJobApi) - return response.data.peJobs[0] } + +export default fetchFtJobDetails diff --git a/ui/services/fetchLbaCompanyDetails.ts b/ui/services/fetchLbaCompanyDetails.ts index 264ec07921..d69398910b 100644 --- a/ui/services/fetchLbaCompanyDetails.ts +++ b/ui/services/fetchLbaCompanyDetails.ts @@ -1,13 +1,21 @@ -import axios from "axios" +import { ILbaItemLbaCompany, zRoutes } from "@/../shared" +import { z } from "zod" -import { companyApi } from "@/components/SearchForTrainingsAndJobs/services/utils" +import { apiGet } from "@/utils/api.utils" -export default async function fetchLbaCompanyDetails(company) { - const res = null - if (!company) { - return res +const zodSchema = zRoutes.get["/v1/jobs/company/:siret"].response["200"] + +const fetchLbaCompanyDetails = async (company): Promise => { + const response = await apiGet("/v1/jobs/company/:siret", { params: { siret: company.id }, querystring: {} }) + + const typedResponse = response as z.output + const [firstLbaCompany] = typedResponse?.lbaCompanies ?? [] + if (firstLbaCompany) { + firstLbaCompany.detailsLoaded = true + return firstLbaCompany + } else { + throw new Error("unexpected_error") } - const lbaCompanyApi = `${companyApi}/${encodeURIComponent(company.id)}` - const response = await axios.get(lbaCompanyApi) - return response.data.lbaCompanies[0] } + +export default fetchLbaCompanyDetails diff --git a/ui/services/fetchLbaJobDetails.ts b/ui/services/fetchLbaJobDetails.ts index b8e503481c..f1e673a216 100644 --- a/ui/services/fetchLbaJobDetails.ts +++ b/ui/services/fetchLbaJobDetails.ts @@ -1,13 +1,20 @@ -import axios from "axios" +import { ILbaItemLbaJob, zRoutes } from "shared" +import { z } from "zod" -import { matchaApi } from "@/components/SearchForTrainingsAndJobs/services/utils" +import { apiGet } from "@/utils/api.utils" -export default async function fetchLbaJobDetails(job) { - const res = null - if (!job) { - return res +const zodSchema = zRoutes.get["/v1/jobs/matcha/:id"].response["200"] + +const fetchLbaJobDetails = async (job): Promise => { + const response = await apiGet("/v1/jobs/matcha/:id", { params: { id: job.id }, querystring: {} }) + const typedResponse = response as z.output + const [firstMatcha] = typedResponse?.matchas ?? [] + if (firstMatcha) { + firstMatcha.detailsLoaded = true + return firstMatcha + } else { + throw new Error("unexpected_error") } - const lbaJobApi = `${matchaApi}/${encodeURIComponent(job.id)}` - const response = await axios.get(lbaJobApi) - return response.data.matchas[0] } + +export default fetchLbaJobDetails diff --git a/ui/services/fetchTrainingDetails.ts b/ui/services/fetchTrainingDetails.ts index ce80ca77ab..28474fcb67 100644 --- a/ui/services/fetchTrainingDetails.ts +++ b/ui/services/fetchTrainingDetails.ts @@ -1,35 +1,21 @@ -import axios from "axios" -import _ from "lodash" +import { ILbaItemFormation, zRoutes } from "shared" +import { z } from "zod" -import { apiEndpoint } from "../config/config" -import { logError } from "../utils/tools" +import { apiGet } from "@/utils/api.utils" -export default async function fetchTrainingDetails(training, errorCallbackFn = _.noop, _apiEndpoint = apiEndpoint, _axios = axios, _window = window, _logError = logError) { - let res = null +const zodSchema = zRoutes.get["/v1/formations/formation/:id"].response["200"] - if (!training) { - return res - } - - const trainingsApi = `${_apiEndpoint}/v1/formations/formation/${encodeURIComponent(training.id)}` - - const response = await _axios.get(trainingsApi) - - const isSimulatedError = _.includes(_.get(_window, "location.href", ""), "trainingDetailError=true") - const isAxiosError = !!_.get(response, "data.error") +const fetchTrainingDetails = async (training): Promise => { + const response = await apiGet("/v1/formations/formation/:id", { params: { id: training.id }, querystring: {} }) - const isError = isAxiosError || isSimulatedError - - if (isError) { - errorCallbackFn() - if (isAxiosError) { - _logError("Training detail API error", `Training detail API error ${response.data.error}`) - } else if (isSimulatedError) { - _logError("Training detail API error simulated with a query param :)") - } - } else if (response.data?.results?.length) { - res = response.data.results[0] + const typedResponse = response as z.output + const [firstTraining] = typedResponse?.results ?? [] + if (firstTraining) { + firstTraining.detailsLoaded = true + return firstTraining + } else { + throw new Error("unexpected_error") } - - return res } + +export default fetchTrainingDetails diff --git a/ui/utils/api.ts b/ui/utils/api.ts index f9fa17e0f4..e42b0c196a 100644 --- a/ui/utils/api.ts +++ b/ui/utils/api.ts @@ -1,12 +1,15 @@ import { captureException } from "@sentry/nextjs" import Axios from "axios" -import { IJobWritable, INewDelegations, IRoutes, IUserRecruteur, IUserStatusValidationJson } from "shared" +import { IJobWritable, INewDelegations, IRoutes, parseEnumOrError, removeUndefinedFields } from "shared" import { BusinessErrorCodes } from "shared/constants/errorCodes" +import { OPCOS } from "shared/constants/recruteur" +import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" +import { IUser2 } from "shared/models/user2.model" import { IEntrepriseInformations } from "shared/routes/recruiters.routes" import { publicConfig } from "../config.public" -import { ApiError, apiDelete, apiGet, apiPost, apiPut, removeUndefinedFields } from "./api.utils" +import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "./api.utils" const API = Axios.create({ baseURL: publicConfig.apiEndpoint, @@ -54,46 +57,50 @@ export const createEtablissementDelegationByToken = ({ data, jobId, token }: { j /** * User API */ -export const getUser = (userId: string) => apiGet("/user/:userId", { params: { userId } }) -const updateUser = (userId: string, user) => apiPut("/user/:userId", { params: { userId }, body: user }) -const updateUserAdmin = (userId: string, user) => apiPut("/admin/users/:userId", { params: { userId }, body: user }) +export const getUser = (userId: string, organizationId: string = "unused") => apiGet("/user/:userId/organization/:organizationId", { params: { userId, organizationId } }) export const getUserStatus = (userId: string) => apiGet("/user/status/:userId", { params: { userId } }) export const getUserStatusByToken = (userId: string, token: string) => apiGet("/user/status/:userId/by-token", { params: { userId }, headers: { authorization: `Bearer ${token}` } }) -export const updateUserValidationHistory = (userId: string, state: IUserStatusValidationJson) => - apiPut("/user/:userId/history", { params: { userId }, body: state }).catch(errorHandler) +export const updateUserValidationHistory = ({ + userId, + organizationId, + reason, + status, + organizationType, +}: { + userId: string + organizationId: string + status: AccessStatus + reason: string + organizationType: typeof AccessEntityType.ENTREPRISE | typeof AccessEntityType.CFA +}) => apiPut("/user/:userId/organization/:organizationId/permission", { params: { userId, organizationId }, body: { organizationType, status, reason } }).catch(errorHandler) export const deleteCfa = async (userId) => await API.delete(`/user`, { params: { userId } }).catch(errorHandler) export const deleteEntreprise = (userId: string, recruiterId: string) => apiDelete(`/user`, { querystring: { userId, recruiterId } }).catch(errorHandler) -export const createUser = (userRecruteur: IUserRecruteur) => apiPost("/admin/users", { body: userRecruteur }) +export const createAdminUser = (user: IUser2) => apiPost("/admin/users", { body: user }) // Temporaire, en attendant d'ajuster le modèle pour n'avoir qu'une seul source de données pour les entreprises /** * KBA 20230511 : (migration db) : casting des valueurs coté collection recruiter, car les champs ne sont plus identiques avec la collection userRecruteur. */ -export const updateEntreprise = async (userId: string, establishment_id: string, user: any) => - await Promise.all([ - updateUser(userId, user), - // - updateFormulaire(establishment_id, user), - ]) - -export const updateEntrepriseAdmin = async (userId: string, establishment_id: string, user: any) => - await Promise.all([ - updateUserAdmin(userId, user), - // - updateFormulaire(establishment_id, user), - ]) +export const updateEntreprise = async (userId: string, user: any) => { + await apiPut("/user/:userId", { params: { userId }, body: user }) +} + +export const updateEntrepriseAdmin = async (userId: string, user: any, siret = "unused") => { + await apiPut("/admin/users/:userId/organization/:siret", { params: { userId, siret }, body: user }) +} /** * Auth API */ export const sendMagiclink = async (email) => await API.post(`/login/magiclink`, email) -export const sendValidationLink = async (userId: string) => await apiPost("/login/:userId/resend-confirmation-email", { params: { userId } }) +export const sendValidationLink = async (userId: string, token: string) => + await apiPost("/login/:userId/resend-confirmation-email", { params: { userId }, headers: { authorization: `Bearer ${token}` } }) /** * Etablissement API */ -export const getEntreprisesManagedByCfa = (userId: string) => apiGet("/etablissement/cfa/:userRecruteurId/entreprises", { params: { userRecruteurId: userId } }) +export const getEntreprisesManagedByCfa = (cfaId: string) => apiGet("/etablissement/cfa/:cfaId/entreprises", { params: { cfaId } }) export const getCfaInformation = async (siret: string) => apiGet("/etablissement/cfa/:siret", { params: { siret } }) export const getEntrepriseInformation = async ( @@ -125,7 +132,7 @@ export const getEntrepriseOpco = async (siret: string) => { export const createEtablissement = (etablissement) => apiPost("/etablissement/creation", { body: etablissement }) -export const getRomeDetail = (rome: string) => API.get(`/rome/detail/${rome}`) +export const getRomeDetail = (rome: string) => apiGet("/rome/detail/:rome", { params: { rome } }) export const getRelatedEtablissementsFromRome = ({ rome, latitude, longitude }: { rome: string; latitude: number; longitude: number }) => API.get(`/etablissement/cfas-proches?rome=${rome}&latitude=${latitude}&longitude=${longitude}`) @@ -141,4 +148,4 @@ export const etablissementUnsubscribeDemandeDelegation = (establishment_siret: a * Administration OPCO */ -export const getOpcoUsers = (opco: string) => apiGet("/user/opco", { querystring: { opco } }) +export const getOpcoUsers = (opco: string) => apiGet("/user/opco", { querystring: { opco: parseEnumOrError(OPCOS, opco) } }) diff --git a/ui/utils/api.utils.ts b/ui/utils/api.utils.ts index fb9e6f28cc..c01fc2bc00 100644 --- a/ui/utils/api.utils.ts +++ b/ui/utils/api.utils.ts @@ -111,6 +111,10 @@ export class ApiError extends Error { return this.context } + isNotFoundError(): boolean { + return this.context.statusCode === 404 + } + static async build(path: string, requestHeaders: Headers, options: WithQueryStringAndPathParam, res: Response): Promise { let message = res.status === 0 ? "Network Error" : res.statusText let name = "Api Error" @@ -207,7 +211,3 @@ export async function apiDelete

>(obj: T): T { - return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined)) as T -} diff --git a/ui/utils/itemListUtils.ts b/ui/utils/itemListUtils.ts index 98bea25f84..e8ede3a3a7 100644 --- a/ui/utils/itemListUtils.ts +++ b/ui/utils/itemListUtils.ts @@ -2,7 +2,7 @@ import { LBA_ITEM_TYPE_OLD } from "shared/constants/lbaitem" // retourne les offres issues du dépôt d'offre simplifié (ex Matcha) triées par ordre croissant de distance au centre de recherche -// suivi des offres pe triées par ordre croissant de distance au centre de recherche. +// suivi des offres France travail triées par ordre croissant de distance au centre de recherche. export const mergeJobs = ({ jobs, activeFilters }) => { let mergedArray = [] @@ -18,7 +18,7 @@ export const mergeJobs = ({ jobs, activeFilters }) => { return mergedArray } -// fusionne les résultats lbb et lba et les trie par ordre croissant de distance, optionnellement intègre aussi les offres PE et matchas +// fusionne les résultats lbb et lba et les trie par ordre croissant de distance, optionnellement intègre aussi les offres France travail et LBA export const mergeOpportunities = ({ jobs, onlyLbbLbaCompanies = undefined, activeFilters }) => { let mergedArray = [] if (jobs) { @@ -67,7 +67,7 @@ const sortMergedSources = (mergedArray) => { return mergedArray } -// détermine si l'offre pe est liée au département avec une géoloc non précisée +// détermine si l'offre France travail est liée au département avec une géoloc non précisée export const isDepartmentJob = (job) => { let isDepartmentJob = false if (!job.place.distance && (!job.place.zipCode || job.place.zipCode.substring(0, 2) === job.place.city.substring(0, 2))) {