diff --git a/cron.json b/cron.json index 2f3531ce..da6d52f5 100644 --- a/cron.json +++ b/cron.json @@ -1,7 +1,8 @@ { "jobs": [ { - "command": "0 2 * * * node outils/sync-démarches-simplifiées.js" + "command": "0 2 * * * node outils/sync-démarches-simplifiées.js", + "size": "S" } ] } \ No newline at end of file diff --git a/index.html b/index.html index 0870dd40..0eb31685 100644 --- a/index.html +++ b/index.html @@ -10,13 +10,18 @@ + + + + + - + - +
diff --git a/package-lock.json b/package-lock.json index 27da9b60..8267b51c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@fastify/static": "^7.0.1", + "baredux": "github:DavidBruant/baredux#v1.0.8", "d3-fetch": "^3.0.1", "date-fns": "^3.6.0", "db-migrate": "^0.11.14", @@ -16,7 +17,9 @@ "fastify": "^4.26.2", "knex": "^3.1.0", "ky": "^1.2.2", - "pg": "^8.11.5" + "page": "^1.11.6", + "pg": "^8.11.5", + "remember": "github:DavidBruant/remember#v1.0.2" }, "devDependencies": { "@rollup/plugin-commonjs": "^25.0.7", @@ -34,7 +37,7 @@ "ts-to-jsdoc": "^2.1.0" }, "engines": { - "node": ">20" + "node": "20" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -891,6 +894,10 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/baredux": { + "version": "1.0.8", + "resolved": "git+ssh://git@github.com/DavidBruant/baredux.git#1b62e43d892aaff0ff5e94e5b7ce51b24cc2e074" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3519,6 +3526,14 @@ "node": ">=6" } }, + "node_modules/page": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/page/-/page-1.11.6.tgz", + "integrity": "sha512-P6e2JfzkBrPeFCIPplLP7vDDiU84RUUZMrWdsH4ZBGJ8OosnwFkcUkBHp1DTIjuipLliw9yQn/ZJsXZvarsO+g==", + "dependencies": { + "path-to-regexp": "~1.2.1" + } + }, "node_modules/parse-database-url": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/parse-database-url/-/parse-database-url-0.3.0.tgz", @@ -3603,6 +3618,19 @@ "node": "14 || >=16.14" } }, + "node_modules/path-to-regexp": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.2.1.tgz", + "integrity": "sha512-DBw9IhWfevR2zCVwEZURTuQNseCvu/Q9f5ZgqMCK0Rh61bDa4uyjPAOy9b55yKiPT59zZn+7uYKxmWwsguInwg==", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-to-regexp/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, "node_modules/path-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", @@ -4038,6 +4066,10 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remember": { + "version": "1.0.2", + "resolved": "git+ssh://git@github.com/DavidBruant/remember.git#4fad3c861473e9c56c410c18b79e45d4e718548e" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 46ed9cb6..e92a1779 100644 --- a/package.json +++ b/package.json @@ -33,14 +33,16 @@ }, "dependencies": { "@fastify/static": "^7.0.1", + "baredux": "github:DavidBruant/baredux#v1.0.8", "d3-fetch": "^3.0.1", "date-fns": "^3.6.0", + "db-migrate": "^0.11.14", + "db-migrate-pg": "^1.5.2", "fastify": "^4.26.2", "knex": "^3.1.0", "ky": "^1.2.2", + "page": "^1.11.6", "pg": "^8.11.5", - "db-migrate": "^0.11.14", - "db-migrate-pg": "^1.5.2" - + "remember": "github:DavidBruant/remember#v1.0.2" } } diff --git a/rollup.config.js b/rollup.config.js index 717b6f9b..2c0cbbe9 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -37,25 +37,13 @@ const plugins = ({cssOutput}) => ([ export default [ { - input: 'scripts/front-end/saisie-especes.js', + input: 'scripts/front-end/main.js', output: { sourcemap: true, format: 'es', - file: 'build/bundle-saisie-especes.js' + file: 'build/rollup-bundle-pitchou.js' }, - plugins: plugins({cssOutput: 'bundle-saisie-especes.css'}), - watch: { - clearScreen: false - } - }, - { - input: 'scripts/front-end/suivi.js', - output: { - sourcemap: true, - format: 'es', - file: 'build/bundle-suivi.js' - }, - plugins: plugins({cssOutput: 'bundle-suivi.css'}), + plugins: plugins({cssOutput: 'rollup-bundle-pitchou.css'}), watch: { clearScreen: false } diff --git a/saisie-especes.html b/saisie-especes.html deleted file mode 100644 index cd67f900..00000000 --- a/saisie-especes.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - Saisie espèces protégées - - - - - - - - - - - - - - - - - - -
-
-
- - diff --git a/scripts/commun/constantes.js b/scripts/commun/constantes.js new file mode 100644 index 00000000..5cc7cf20 --- /dev/null +++ b/scripts/commun/constantes.js @@ -0,0 +1,4 @@ +export const authorizedEmailDomains = new Set([ + 'developpement-durable.gouv.fr', + 'beta.gouv.fr' +]) \ No newline at end of file diff --git a/scripts/front-end/actions/main.js b/scripts/front-end/actions/main.js new file mode 100644 index 00000000..ab46338e --- /dev/null +++ b/scripts/front-end/actions/main.js @@ -0,0 +1,31 @@ +//@ts-check + +import store from '../store.js'; +import remember from 'remember' + +const PITCHOU_SECRET_STORAGE_KEY = 'secret-pitchou' + +export function init(){ + + return remember(PITCHOU_SECRET_STORAGE_KEY) + .then(secret => { + if(secret){ + // @ts-ignore + store.mutations.setSecret(secret) + } + }) +} + +export async function secretFromURL(){ + const secret = new URLSearchParams(location.search).get("secret") + + if(secret){ + const newURL = new URL(location.href) + newURL.searchParams.delete("secret") + + history.replaceState(null, "", newURL) + store.mutations.setSecret(secret) + + return remember(PITCHOU_SECRET_STORAGE_KEY, secret) + } +} \ No newline at end of file diff --git a/scripts/front-end/components/LoginViaEmail.svelte b/scripts/front-end/components/LoginViaEmail.svelte new file mode 100644 index 00000000..72c80bf6 --- /dev/null +++ b/scripts/front-end/components/LoginViaEmail.svelte @@ -0,0 +1,33 @@ + + + +

Connexion par email

+ +

Saisissez votre adresse email ici et vous recevrez un email avec un lien secret pour vous connecter à Pitchou.

+

⚠️ Seules les adresses emails venant d'un de ces domaine peuvent recevoir un lien de connexion : + {#each [...authorizedEmailDomains] as authorizedEmailDomain, i} + {#if i !== 0} ,  {/if} + {authorizedEmailDomain} + {/each} +

+ +
envoiEmailConnexion(email)}> + + +
+ + + diff --git a/scripts/front-end/components/SuiviInstructeur.svelte b/scripts/front-end/components/SuiviInstructeur.svelte index e650cd01..77b0bc12 100644 --- a/scripts/front-end/components/SuiviInstructeur.svelte +++ b/scripts/front-end/components/SuiviInstructeur.svelte @@ -1,7 +1,5 @@ + +

Suivi instructeur

diff --git a/scripts/front-end/main.js b/scripts/front-end/main.js new file mode 100644 index 00000000..64805f99 --- /dev/null +++ b/scripts/front-end/main.js @@ -0,0 +1,279 @@ +//@ts-check + +import page from 'page' + +import {json, dsv} from 'd3-fetch' + +import LoginViaEmail from './components/LoginViaEmail.svelte'; +import SuiviInstructeur from './components/SuiviInstructeur.svelte'; +import SaisieEspèces from './components/SaisieEspèces.svelte'; +import { replaceComponent } from './routeComponentLifeCycle.js' +import store from './store.js' + +import {init, secretFromURL} from './actions/main.js' +import {envoiEmailConnexion} from './serveur.js' + +import { authorizedEmailDomains } from '../commun/constantes.js'; + +import '../types.js' + +const svelteTarget = document.querySelector('.svelte-main') + + +page('/', async () => { + console.info('route', '/') + const secret = await secretFromURL().then(() => store.state.secret) + + if(secret){ + json(`/dossiers?secret=${secret}`).then(dossiers => { + function mapStateToProps(){ + return {dossiers} + } + + console.log('dossiers', dossiers) + + const suiviInstructeur = new SuiviInstructeur({ + target: svelteTarget, + props: mapStateToProps() + }); + + replaceComponent(suiviInstructeur, mapStateToProps) + }) + } + else{ + function mapStateToProps(){ + return { + authorizedEmailDomains, + envoiEmailConnexion: envoiEmailConnexion + } + } + + const loginViaEmail = new LoginViaEmail({ + target: svelteTarget, + props: mapStateToProps() + }); + + replaceComponent(loginViaEmail, mapStateToProps) + } + +}) + +page('/saisie-especes', async () => { + /** + * @param {string} x + * @returns {x is ClassificationEtreVivant} + */ + function isClassif(x){ + // @ts-expect-error indeed + return classificationEtreVivants.includes(x) + } + + function getURL(selector){ + const element = document.head.querySelector(selector) + + if(!element){ + throw new TypeError(`Élément ${selector} manquant`) + } + + const hrefAttribute = element.getAttribute('href') + + if(!hrefAttribute){ + throw new TypeError(`Attribut "href" manquant sur ${selector}`) + } + + return hrefAttribute + } + + /** @type { [any[], ActivitéMenançante[], MéthodeMenançante[], TransportMenançant[]] } */ + // @ts-ignore + const [dataEspèces, activites, methodes, transports] = await Promise.all([ + dsv(";", getURL('link#especes-data')), + dsv(";", getURL('link#activites-data')), + dsv(";", getURL('link#methodes-data')), + dsv(";", getURL('link#transports-data')), + ]) + + console.log(dataEspèces, activites, methodes, transports) + + /** @type {readonly ClassificationEtreVivant[]} */ + const classificationEtreVivants = Object.freeze(["oiseau", "faune non-oiseau", "flore"]) + + + + /** @type {Map} */ + const activitesParClassificationEtreVivant = new Map() + for(const activite of activites){ + const classif = activite['Espèces'] + + if(!classif.trim() && !activite['Code']){ + // ignore empty lines (certainly comments) + break; + } + + if(!isClassif(classif)){ + throw new TypeError(`Classification d'espèce non reconnue: ${classif}. Les choix sont : ${classificationEtreVivants.join(', ')}`) + } + + const classifActivz = activitesParClassificationEtreVivant.get(classif) || [] + classifActivz.push(activite) + activitesParClassificationEtreVivant.set(classif, classifActivz) + } + + /** @type {Map} */ + const méthodesParClassificationEtreVivant = new Map() + for(const methode of methodes){ + const classif = methode['Espèces'] + + if(!classif.trim() && !methode['Code']){ + // ignore empty lines (certainly comments) + break; + } + + if(!isClassif(classif)){ + throw new TypeError(`Classification d'espèce non reconnue: ${classif}. Les choix sont : ${classificationEtreVivants.join(', ')}`) + } + + const classifMeth = méthodesParClassificationEtreVivant.get(classif) || [] + classifMeth.push(methode) + méthodesParClassificationEtreVivant.set(classif, classifMeth) + } + + /** @type {Map} */ + const transportsParClassificationEtreVivant = new Map() + for(const transport of transports){ + const classif = transport['Espèces'] + + if(!classif.trim() && !transport['Code']){ + // ignore empty lines (certainly comments) + break; + } + + if(!isClassif(classif)){ + throw new TypeError(`Classification d'espèce non reconnue: ${classif}. Les choix sont : ${classificationEtreVivants.join(', ')}`) + } + + const classifTrans = transportsParClassificationEtreVivant.get(classif) || [] + classifTrans.push(transport) + transportsParClassificationEtreVivant.set(classif, classifTrans) + } + + + + const espèceByCD_NOM = new Map() + dataEspèces.forEach(d => { + espèceByCD_NOM.set(d["CD_NOM"], d) + }) + console.log(espèceByCD_NOM) + + /** @type { Espèce[] } */ + const listeEspècesProtégées = [...espèceByCD_NOM.values()] + + const filtreParClassification = new Map([ + ["oiseau", ((/** @type {{REGNE: Règne, CLASSE: Classe}} */ {REGNE, CLASSE}) => { + return REGNE === 'Animalia' && CLASSE === 'Aves' + })], + ["faune non-oiseau", ((/** @type {{REGNE: Règne, CLASSE: Classe}} */ {REGNE, CLASSE}) => { + return REGNE === 'Animalia' && CLASSE !== 'Aves' + })], + ["flore", ((/** @type {{REGNE: Règne, CLASSE: Classe}} */ {REGNE}) => { + return REGNE === 'Plantae' + })] + ]) + + const espècesProtégéesParClassification = new Map( + [...filtreParClassification].map(([classif, filtre]) => ([classif, listeEspècesProtégées.filter(filtre)])) + ) + + console.log('espècesProtégéesParClassification', espècesProtégéesParClassification) + + /** + * + * @param {string} s // utf-8-encoded base64 string + * @returns {string} // cleartext string + */ + function b64ToUTF8(s) { + return decodeURIComponent(escape(atob(s))) + } + + + /** + * + * @param { DescriptionMenaceEspècesJSON } descriptionMenacesEspècesJSON + * @returns { DescriptionMenaceEspèce[] } + */ + function descriptionMenacesEspècesFromJSON(descriptionMenacesEspècesJSON){ + return descriptionMenacesEspècesJSON.map(({classification, etresVivantsAtteints}) => { + console.log('classification, etresVivantsAtteints', classification, etresVivantsAtteints) + return { + classification, + etresVivantsAtteints: etresVivantsAtteints.map(({espece, activité, méthode, transport, ...rest}) => ({ + espece: espèceByCD_NOM.get(espece), + activité: activites.find((a) => a.Code === activité), + méthode: methodes.find((m) => m.Code === méthode), + transport: transports.find((t) => t.Espèces === classification && t.Code === transport), + ...rest + })), + + } + }) + } + + function importDescriptionMenacesEspècesFromURL(){ + const urlData = new URLSearchParams(location.search).get('data') + if(urlData){ + try{ + const data = JSON.parse(b64ToUTF8(urlData)) + const desc = descriptionMenacesEspècesFromJSON(data) + console.log('desc', desc) + return desc + } + catch(e){ + console.error('Parsing error', e, urlData) + return undefined + } + } + } + + function mapStateToProps(){ + return { + espècesProtégéesParClassification, + activitesParClassificationEtreVivant, + méthodesParClassificationEtreVivant, + transportsParClassificationEtreVivant, + /** @type {DescriptionMenaceEspèce[]} */ + // @ts-ignore + descriptionMenacesEspèces: importDescriptionMenacesEspècesFromURL() || [ + { + classification: "oiseau", // Type d'espèce menacée + etresVivantsAtteints: [], + activité: undefined, // Activité menaçante + méthode: undefined, // Méthode menaçante + transport: undefined // Transport impliqué dans la menace + }, + { + classification: "faune non-oiseau", + etresVivantsAtteints: [], + activité: undefined, // Activité menaçante + méthode: undefined, // Méthode menaçante + transport: undefined // Transport impliqué dans la menace + }, + { + classification: "flore", + etresVivantsAtteints: [], + activité: undefined, // Activité menaçante + } + ] + } + } + + + const saisieEspèces = new SaisieEspèces({ + target: svelteTarget, + props: mapStateToProps() + }); + + replaceComponent(saisieEspèces, mapStateToProps) +}) + +init() +.then(() => page.start()) \ No newline at end of file diff --git a/scripts/front-end/routeComponentLifeCycle.js b/scripts/front-end/routeComponentLifeCycle.js new file mode 100644 index 00000000..8afcacc7 --- /dev/null +++ b/scripts/front-end/routeComponentLifeCycle.js @@ -0,0 +1,42 @@ +//@ts-check + +import { SvelteComponent } from "svelte"; +import store from "./store.js"; + +/** @typedef {import("./store.js").PitchouState} PitchouState */ +/** @typedef {(state: PitchouState) => any} MapStateToPropsFunction */ + +/** @type {SvelteComponent} */ +let currentComponent; +/** @type {MapStateToPropsFunction} */ +let currentMapStateToProps = (_) => {}; + +/** + * + * @param {SvelteComponent} newComponent + * @param {MapStateToPropsFunction} newMapStateToProps + */ +export function replaceComponent(newComponent, newMapStateToProps) { + if (!newMapStateToProps) { + throw new Error("Missing _mapStateToProps in replaceComponent"); + } + + if (currentComponent) currentComponent.$destroy(); + + currentComponent = newComponent; + currentMapStateToProps = newMapStateToProps; +} + +/** + * + * @param {PitchouState} state + */ +function render(state) { + const props = currentMapStateToProps(state); + // @ts-ignore + if (props) { + currentComponent.$set(props); + } +} + +store.subscribe(render); diff --git a/scripts/front-end/saisie-especes.js b/scripts/front-end/saisie-especes.js deleted file mode 100644 index f4cfa251..00000000 --- a/scripts/front-end/saisie-especes.js +++ /dev/null @@ -1,216 +0,0 @@ -//@ts-check - -import { dsv } from 'd3-fetch'; - -import SaisieEspèces from './components/SaisieEspèces.svelte'; - -import '../types.js' - -/** - * @param {string} x - * @returns {x is ClassificationEtreVivant} - */ -function isClassif(x){ - // @ts-expect-error indeed - return classificationEtreVivants.includes(x) -} - -function getURL(selector){ - const element = document.head.querySelector(selector) - - if(!element){ - throw new TypeError(`Élément ${selector} manquant`) - } - - const hrefAttribute = element.getAttribute('href') - - if(!hrefAttribute){ - throw new TypeError(`Attribut "href" manquant sur ${selector}`) - } - - return hrefAttribute -} - -/** @type { [any[], ActivitéMenançante[], MéthodeMenançante[], TransportMenançant[]] } */ -// @ts-ignore -const [dataEspèces, activites, methodes, transports] = await Promise.all([ - dsv(";", getURL('link#especes-data')), - dsv(";", getURL('link#activites-data')), - dsv(";", getURL('link#methodes-data')), - dsv(";", getURL('link#transports-data')), -]) - -console.log(dataEspèces, activites, methodes, transports) - -/** @type {readonly ClassificationEtreVivant[]} */ -const classificationEtreVivants = Object.freeze(["oiseau", "faune non-oiseau", "flore"]) - - - -/** @type {Map} */ -const activitesParClassificationEtreVivant = new Map() -for(const activite of activites){ - const classif = activite['Espèces'] - - if(!classif.trim() && !activite['Code']){ - // ignore empty lines (certainly comments) - break; - } - - if(!isClassif(classif)){ - throw new TypeError(`Classification d'espèce non reconnue: ${classif}. Les choix sont : ${classificationEtreVivants.join(', ')}`) - } - - const classifActivz = activitesParClassificationEtreVivant.get(classif) || [] - classifActivz.push(activite) - activitesParClassificationEtreVivant.set(classif, classifActivz) -} - -/** @type {Map} */ -const méthodesParClassificationEtreVivant = new Map() -for(const methode of methodes){ - const classif = methode['Espèces'] - - if(!classif.trim() && !methode['Code']){ - // ignore empty lines (certainly comments) - break; - } - - if(!isClassif(classif)){ - throw new TypeError(`Classification d'espèce non reconnue: ${classif}. Les choix sont : ${classificationEtreVivants.join(', ')}`) - } - - const classifMeth = méthodesParClassificationEtreVivant.get(classif) || [] - classifMeth.push(methode) - méthodesParClassificationEtreVivant.set(classif, classifMeth) -} - -/** @type {Map} */ -const transportsParClassificationEtreVivant = new Map() -for(const transport of transports){ - const classif = transport['Espèces'] - - if(!classif.trim() && !transport['Code']){ - // ignore empty lines (certainly comments) - break; - } - - if(!isClassif(classif)){ - throw new TypeError(`Classification d'espèce non reconnue: ${classif}. Les choix sont : ${classificationEtreVivants.join(', ')}`) - } - - const classifTrans = transportsParClassificationEtreVivant.get(classif) || [] - classifTrans.push(transport) - transportsParClassificationEtreVivant.set(classif, classifTrans) -} - - - -const espèceByCD_NOM = new Map() -dataEspèces.forEach(d => { - espèceByCD_NOM.set(d["CD_NOM"], d) -}) -console.log(espèceByCD_NOM) - -/** @type { Espèce[] } */ -const listeEspècesProtégées = [...espèceByCD_NOM.values()] - -const filtreParClassification = new Map([ - ["oiseau", ((/** @type {{REGNE: Règne, CLASSE: Classe}} */ {REGNE, CLASSE}) => { - return REGNE === 'Animalia' && CLASSE === 'Aves' - })], - ["faune non-oiseau", ((/** @type {{REGNE: Règne, CLASSE: Classe}} */ {REGNE, CLASSE}) => { - return REGNE === 'Animalia' && CLASSE !== 'Aves' - })], - ["flore", ((/** @type {{REGNE: Règne, CLASSE: Classe}} */ {REGNE}) => { - return REGNE === 'Plantae' - })] -]) - -const espècesProtégéesParClassification = new Map( - [...filtreParClassification].map(([classif, filtre]) => ([classif, listeEspècesProtégées.filter(filtre)])) -) - -console.log('espècesProtégéesParClassification', espècesProtégéesParClassification) - -/** - * - * @param {string} s // utf-8-encoded base64 string - * @returns {string} // cleartext string - */ -function b64ToUTF8(s) { - return decodeURIComponent(escape(atob(s))) -} - - -/** - * - * @param { DescriptionMenaceEspècesJSON } descriptionMenacesEspècesJSON - * @returns { DescriptionMenaceEspèce[] } - */ -function descriptionMenacesEspècesFromJSON(descriptionMenacesEspècesJSON){ - return descriptionMenacesEspècesJSON.map(({classification, etresVivantsAtteints}) => { - console.log('classification, etresVivantsAtteints', classification, etresVivantsAtteints) - return { - classification, - etresVivantsAtteints: etresVivantsAtteints.map(({espece, activité, méthode, transport, ...rest}) => ({ - espece: espèceByCD_NOM.get(espece), - activité: activites.find((a) => a.Code === activité), - méthode: methodes.find((m) => m.Code === méthode), - transport: transports.find((t) => t.Espèces === classification && t.Code === transport), - ...rest - })), - - } - }) -} - -function importDescriptionMenacesEspècesFromURL(){ - const urlData = new URLSearchParams(location.search).get('data') - if(urlData){ - try{ - const data = JSON.parse(b64ToUTF8(urlData)) - const desc = descriptionMenacesEspècesFromJSON(data) - console.log('desc', desc) - return desc - } - catch(e){ - console.error('Parsing error', e, urlData) - return undefined - } - } -} - - -const app = new SaisieEspèces({ - target: document.querySelector('.svelte-main'), - props: { - espècesProtégéesParClassification, - activitesParClassificationEtreVivant, - méthodesParClassificationEtreVivant, - transportsParClassificationEtreVivant, - /** @type {DescriptionMenaceEspèce[]} */ - // @ts-ignore - descriptionMenacesEspèces: importDescriptionMenacesEspècesFromURL() || [ - { - classification: "oiseau", // Type d'espèce menacée - etresVivantsAtteints: [], - activité: undefined, // Activité menaçante - méthode: undefined, // Méthode menaçante - transport: undefined // Transport impliqué dans la menace - }, - { - classification: "faune non-oiseau", - etresVivantsAtteints: [], - activité: undefined, // Activité menaçante - méthode: undefined, // Méthode menaçante - transport: undefined // Transport impliqué dans la menace - }, - { - classification: "flore", - etresVivantsAtteints: [], - activité: undefined, // Activité menaçante - } - ] - } -}); diff --git a/scripts/front-end/serveur.js b/scripts/front-end/serveur.js new file mode 100644 index 00000000..1655a94f --- /dev/null +++ b/scripts/front-end/serveur.js @@ -0,0 +1,13 @@ + +import {json, dsv} from 'd3-fetch' + + +/** + * + * @param {string} email + */ +export function envoiEmailConnexion(email){ + return json(`/envoi-email-connexion?email=${encodeURIComponent(email)}`, { + method: 'POST' + }) +} \ No newline at end of file diff --git a/scripts/front-end/store.js b/scripts/front-end/store.js new file mode 100644 index 00000000..a383a59e --- /dev/null +++ b/scripts/front-end/store.js @@ -0,0 +1,45 @@ +//@ts-check + +import Store from 'baredux' +/** + * Un store baredux a pour vocation de refléter notamment le modèle mental de la + * personne face à notre application. Le store stocke donc principalement des données (et parfois des singletons) + * Il stocke aussi parfois des promesses (pour permettre d'afficher des loaders dans les composants) + * + * Dans un store Baredux, les mutations sont synchrones + * S'il manque des informations, attendre la résolution de la promesse avant d'appeler une mutation + * (à moins que la valeur soit délibérément une promesse) + * + */ +// DO NOT import x from 'remember' // do it in an action instead +// DO NOT import x from './actions/*.js' // you're making an action, so add an action instead + +import '../types.js' + + +/** + * @typedef {Object} PitchouState + * @property {string} [secret] + */ + +/** @type {PitchouState} */ +const state = { + secret: undefined +} + +const mutations = { + /** + * @param {PitchouState} state + * @param {PitchouState['secret']} secret + */ + setSecret(state, secret) { + state.secret = secret + } +} + +/** @typedef { typeof mutations } PitchouMutations */ + +/** @type { import('baredux').BareduxStore } */ +const store = Store({ state, mutations }) + +export default store diff --git a/scripts/front-end/suivi.js b/scripts/front-end/suivi.js deleted file mode 100644 index 06b8a99e..00000000 --- a/scripts/front-end/suivi.js +++ /dev/null @@ -1,26 +0,0 @@ -//@ts-check - -import {json} from 'd3-fetch' - -import SuiviInstructeur from './components/SuiviInstructeur.svelte'; - -import '../types.js' -const secret = new URLSearchParams(location.search).get("secret") - -json(`/dossiers?secret=${secret}`).then(dossiers => { - const newURL = new URL(location.href) - newURL.searchParams.delete("secret") - - history.replaceState(null, "", newURL) - - console.log('dossiers', dossiers) - const app = new SuiviInstructeur({ - target: document.querySelector('.svelte-main'), - props: { - dossiers - } - }); -}) - - - diff --git "a/scripts/server/cr\303\251er-premi\303\250re-personne.js" "b/scripts/server/cr\303\251er-premi\303\250re-personne.js" deleted file mode 100644 index f551b947..00000000 --- "a/scripts/server/cr\303\251er-premi\303\250re-personne.js" +++ /dev/null @@ -1,19 +0,0 @@ -import { créerPersonne } from "./database.js"; - -const personneData = { - nom: 'Rispal', - prénoms: 'Vanessa', - email: 'vanessa.rispal@developpement-durable.gouv.fr', - code_accès: Math.random().toString(36).slice(2) -} - -export default function(){ - return créerPersonne(personneData) - .then(() => { - console.log('Première personne créée 🎉') - console.log(`URL d'accès:`, `https://especes-protegees.osc-fr1.scalingo.io/?secret=${personneData.code_accès}`) - }) - .catch(err => { - console.error('Erreur lors de la création de la première personne', err) - }) -} \ No newline at end of file diff --git a/scripts/server/database.js b/scripts/server/database.js index c1896037..a26e8338 100644 --- a/scripts/server/database.js +++ b/scripts/server/database.js @@ -2,6 +2,9 @@ import knex from 'knex'; +/** @typedef {import('../types/database/public/Personne.js').default} Personne */ +/** @typedef {import('../types/database/public/Dossier.js').default} Dossier */ + const DATABASE_URL = process.env.DATABASE_URL if(!DATABASE_URL){ throw new TypeError(`Variable d'environnement DATABASE_URL manquante`) @@ -19,15 +22,12 @@ const database = knex({ export function créerPersonne(personne){ return database('personne') .insert(personne) - .catch(err => { - console.error('Error trying to create a person', err) - throw err - }) } + /** * - * @param {string} code_accès - * @returns {Promise | Promise} + * @param {Personne['code_accès']} code_accès + * @returns {Promise | Promise} */ export function getPersonneByCode(code_accès) { return database('personne') @@ -36,7 +36,56 @@ export function getPersonneByCode(code_accès) { .first() } +/** + * + * @param {Personne['email']} email + * @returns {Promise | Promise} + */ +export function getPersonneByEmail(email) { + return database('personne') + .where({ email }) + .select() + .first() +} + +/** + * + * @returns {Promise} + */ export function getAllDossier() { return database('dossier') .select() +} + +/** + * + * @param {Personne['email']} email + * @param {Personne['code_accès']} code_accès + * @returns + */ +function updateCodeAccès(email, code_accès){ + return database('personne') + .where({ email }) + .update({code_accès}) +} + +/** + * + * @param {Personne['email']} email + * @returns {Promise} + */ +export function créerPersonneOuMettreÀJourCodeAccès(email){ + const codeAccès = Math.random().toString(36).slice(2) + + return créerPersonne({ + nom: '', + prénoms: '', + email, + code_accès: codeAccès + }) + .catch(err => { + // suppose qu'il y a une erreur parce qu'une personne avec cette adresse email existe déjà + return updateCodeAccès(email, codeAccès) + }) + .then(() => codeAccès) } \ No newline at end of file diff --git a/scripts/server/emails.js b/scripts/server/emails.js new file mode 100644 index 00000000..595a7fc2 --- /dev/null +++ b/scripts/server/emails.js @@ -0,0 +1,15 @@ +/** + * Pour le moment, cette fonction est complètement fake, mais à terme, + * elle enverra un email + * + * @param {string} email + * @param {string} lienConnexion + * @returns {Promise} + */ +export function envoyerEmailConnexion(email, lienConnexion){ + + return Promise.resolve({ + email, + lienConnexion + }) +} \ No newline at end of file diff --git a/scripts/server/main.js b/scripts/server/main.js index b4d2a485..efbb853e 100644 --- a/scripts/server/main.js +++ b/scripts/server/main.js @@ -5,11 +5,10 @@ import path from 'node:path' import Fastify from 'fastify' import fastatic from '@fastify/static' -import créerPremièrePersonne from './créer-première-personne.js' +import { getPersonneByCode, getAllDossier, créerPersonneOuMettreÀJourCodeAccès } from './database.js' -import { getPersonneByCode, getAllDossier } from './database.js' - -const fastify = Fastify({logger: true}) +import { authorizedEmailDomains } from '../commun/constantes.js' +import { envoyerEmailConnexion } from './emails.js' const PORT = parseInt(process.env.PORT || '') if(!PORT){ @@ -27,15 +26,21 @@ if(!DEMARCHE_NUMBER){ } + + +const fastify = Fastify({logger: true}) + fastify.register(fastatic, { root: path.resolve(import.meta.dirname, '..', '..'), extensions: ['html'] }) +fastify.get('/saisie-especes', (request, reply) => { + reply.sendFile('index.html') +}) -créerPremièrePersonne() // Privileged routes -fastify.get('/dossiers', async function handler (request, reply) { +fastify.get('/dossiers', async function (request, reply) { // @ts-ignore const code_accès = request.query.secret if (code_accès) { @@ -46,10 +51,36 @@ fastify.get('/dossiers', async function handler (request, reply) { reply.code(403).send("Code d'accès non valide.") } } else { - reply.code(400).send("Paramètre secret manquant dans l'URL.") + reply.code(400).send(`Paramètre 'secret' manquant dans l'URL`) + } +}) + +fastify.post('/envoi-email-connexion', async function (request, reply) { + // @ts-ignore + const email = decodeURIComponent(request.query.email) + + if(!email){ + return reply.code(400).send(`Paramètre 'email' manquant dans l'URL`) } + + const [name, domain] = email.split('@') + + if(!authorizedEmailDomains.has(domain)){ + return reply.code(403).send(`Le domaine '${domain}' ne fait pas partie des domaines autorisés`) + } + else{ + // le domaine est autorisé + return créerPersonneOuMettreÀJourCodeAccès(email) + .then(codeAccès => { + const lienConnexion = `${request.headers.origin}/?secret=${codeAccès}` + // PPP enlever le return quand on enverra pour de vrai un email + return envoyerEmailConnexion(email, lienConnexion) + }) + } + }) + // Run the server! try { await fastify.listen({ port: PORT, host: '0.0.0.0' })