Node.js : Koa, Firebase et Firestore gestion de vos offres commerciales (Partie 2 : Authentification, CRUD)

Node.js : Koa, Firebase et Firestore gestion de vos offres commerciales (Partie 2 : Authentification, CRUD)
Categories:
Posté le

Dans cette deuxième partie de projet (partie 1 ici) nous allons continuer l'implémentation de notre plateforme de mise en avant des offres commerciales.

Les différents éléments mise en place dans cette partie :

  • Authentification avec un couple unique login/password
  • Nouveau routeur avec Joi router afin de pouvoir gérer aisément la validation des formulaires
  • CRUD des offres avec stockage sur Firestore (la base NoSQL de Firebase)
  • Système de messages flash afin de rendre l'utilisation plus intuitive

Il va donc s'agir dans un premier temps d'installer les différentes dépendances nécessaires à l'implémentation de ces nouvelles features ainsi que de les configurer grâce aux variables d’environnement.

Les sources de ce chapitre d'implémentation : https://github.com/Zenicheck/blog/tree/master/active-offer-koa-v2

Configuration de la suite projet :

$ cat package.json | tail -n 16
  "dependencies": {
    "bcrypt": "^4.0.1",
    "dotenv": "^8.2.0",
    "firebase-admin": "^8.10.0",
    "koa": "^2.11.0",
    "koa-better-flash": "0.0.4",
    "koa-bodyparser": "^4.3.0",
    "koa-ejs": "^4.3.0",
    "koa-generic-session": "^2.0.4",
    "koa-joi-router": "^6.0.2",
    "koa-logger": "^3.2.1",
    "koa-passport": "^4.1.3",
    "koa-static": "^5.0.0",
    "passport-local": "^1.0.0"
  }
}

Il est important de vérifier que les dépendances sont bien présentes puis de les installer :

$ npm i

Nous allons ensuite configurer les différentes variables d'environnement :

$ cat .env
USERNAME=contact@yanncarlen.com
PASSWORD=YOUR-PASSWORD
GEN_SALT=10
FIREBASE_HOST=https://YOUR-HOST.firebaseio.com
KEYS_1=YOUR-KEY_1
KEYS_2=YOUR-KEY_2  

Il faut donc remplacer les différentes variables par des valeurs propres à votre application :

  • USERNAME, votre adresse mail
  • YOUR-PASSWORD, se référer à la première partie pour voir comment le générer
  • YOUR-HOST, vous trouverez cette information dans la configuration de votre projet Firebase
  • YOUR-KEY_1, YOUR-KEY_2, vous pouvez générer des hash ici en spécifiant un digest de 1 par exemple afin d'obtenir un hash de taille convenable

Nous allons ensuite créer les différentes vues du CRUD :

La vue contenant la datatable des offres :

<!-- adm-offer.html.ejs -->
<%- include('title', { title, extra : '<a href="/adm/offer/add" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm"><i class="fas fa-ad fa-sm text-white-50"></i> Ajouter une offre</a>' }); %>
<!-- Content Row -->
<div class="row card shadow">
    <div class="card-body">
        <div class="table-responsive">
            <table class="table table-bordered" id="offerTable" width="100%" cellspacing="0">
                <thead>
                    <tr>
                        <th>Titre</th>
                        <th>Lien</th>
                        <th>Mise en avant</th>
                        <th>Action</th>
                    </tr>
                </thead>
                <tfoot>
                    <tr>
                        <th>Titre</th>
                        <th>Lien</th>
                        <th>Mise en avant</th>
                        <th>Action</th>
                    </tr>
                </tfoot>
                <tbody>
                    <% offers.forEach(function (e) { %>
                    <tr>
                        <td><%- e.offerTitle %></td>
                        <td><%- e.offerLink %></td>
                        <td><% if (!e.offerPublished) { %>
                            <span class="btn btn-info">Non</span>
                            <% } else { %>
                            <span class="btn btn-success">Oui</span>
                            <% } %>
                        </td>
                        <!-- Add label -->
                        <td>
                            <a href="/adm/offer/edit/<%= e.id %>" class="btn btn-primary"><span class="text"
                                    style="color:azure;">Editer</span></a>
                            <% if (e.offerPublished) { %>
                            <a href="/adm/offer/publish/<%= e.id %>" class="btn btn-danger"><span class="text"
                                    style="color:azure;">Dépublier</span></a>
                            <% } else { %>
                            <a href="/adm/offer/publish/<%= e.id %>" class="btn btn-success"><span class="text"
                                    style="color:azure;">Publier</span></a>
                            <% } %>
                            <a href="/adm/offer/delete/<%= e.id %>" class="btn btn-warning"><span class="text"
                                    style="color:azure;">Supprimer</span></a>
                            <!-- Add link -->
                        </td>
                    </tr>
                    <% }) %>
                </tbody>
            </table>
        </div>
    </div>
</div>

Le template permettant d'effectuer les différentes actions sur une offre en fonction de l'action passée :

<!-- adm-offer-action.html.ejs -->
<%- include('title', { title }); %>
<!-- Content Row -->
<div class="row card shadow  col-sm-3">
    <div class="card-body">
        <%- include('error'); %>
        <% if (locals.delete) { %>
        <form class="user" action="/adm/offer/unstore" method="POST">
            <% if (locals.offer) { %>
            <input type="hidden" name="offerId" value="<%= offer.id %>">
            <% } %>
            <p><%- offer.offerTitle %></p>
            <button type="submit" class="btn btn-primary btn-user btn-block">
                Supprimer l'offre
            </button>
            <hr>
        </form>
        <a href="/adm/offer" class="btn btn-warning">Non</a>
        <% } else if (locals.publish) { %>
        <form class="user" action="/adm/offer/release" method="POST">
            <% if (locals.offer) { %>
            <input type="hidden" name="offerId" value="<%= offer.id %>">
            <% } %>
            <p><%- offer.offerTitle %></p>
            <button type="submit" class="btn btn-primary btn-user btn-block">
                <% if (!offer.offerPublished) { %>Publier l'offre<% } else { %>Dépublier l'offre<% } %>
            </button>
            <hr>
        </form>
        <a href="/adm/offer" class="btn btn-warning">Non</a>
        <% } else { %>
        <form class="user" action="/adm/offer/store" method="POST">
            <% if (locals.offer) { %>
            <input type="hidden" name="offerId" value="<%= offer.id %>">
            <% } else if (ctx.request.body.offerId) { %>
            <input type="hidden" name="offerId" value="<%= offerId %>">
            <% } %>
            <div class="form-group row">
                <input type="text" class="form-control form-control-user" id="offerTitle" name="offerTitle"
                    placeholder="Texte de l'annonce"
                    value="<% if (ctx.request.body.offerTitle) { %><%= ctx.request.body.offerTitle %><% }%>" required>
            </div>
            <div class="form-group row">
                <input type="url" class="form-control form-control-user" id="offerLink" placeholder="Lien de l'annonce"
                    name="offerLink"
                    value="<% if (ctx.request.body.offerLink) { %><%= ctx.request.body.offerLink %><% }%>" required>
            </div>
            <button type="submit" class="btn btn-primary btn-user btn-block">
                Ajouter l'offre
            </button>
            <hr>
        </form>
        <% } %>
    </div>
</div>

Les helpers pour la gestion des erreurs du formulaire et de la gestion du titre de la page :

<!-- error.html.ejs -->
<% if (locals.errors) { %>
<div class="card bg-danger text-white shadow" style="margin-bottom: 20px;">
    <div class="card-body">
        <%- locals.errors.msg %>
    </div>
</div>
<% } %>
<!-- title.html.ejs -->
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
    <h1 class="h3 mb-0 text-gray-800"><%= title %></h1>
    <% if (locals.extra) { %>
        <%- extra %>
    <% } %>
</div>

Ainsi que la vue pour l'authentification :

<!DOCTYPE html>
<html lang="fr">
<!-- so.html.ejs -->
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="Zenicheck - Gestion de vos offres commerciales">
    <meta name="author" content="Yann Carlen - Zenicheck">

    <title><%= `${ctx.title} - ${title}` %></title>

    <!-- Custom fonts for this template-->
    <link href="/vendor/fontawesome-free/css/all.min.css" rel="stylesheet" type="text/css">
    <link
        href="https://fonts.googleapis.com/css?family=Nunito:200,200i,300,300i,400,400i,600,600i,700,700i,800,800i,900,900i"
        rel="stylesheet">

    <!-- Custom styles for this template-->
    <link href="/css/sb-admin-2.min.css" rel="stylesheet">
</head>

<body class="bg-gradient-primary">

    <div class="container">

        <!-- Outer Row -->
        <div class="row justify-content-center">

            <div class="col-xl-10 col-lg-12 col-md-9">

                <div class="card o-hidden border-0 shadow-lg my-5">
                    <div class="card-body p-0">
                        <!-- Nested Row within Card Body -->
                        <div class="row">
                            <div class="col-lg-6 d-none d-lg-block bg-login-image"></div>
                            <div class="col-lg-6">
                                <div class="p-5">
                                    <div class="text-center">
                                        <h1 class="h4 text-gray-900 mb-4">Welcome Back!</h1>
                                    </div>
                                    <%- include('error'); %>
                                    <form class="user" action="/so" method="POST">
                                        <div class="form-group">
                                            <input type="email" class="form-control form-control-user" id="soEmail"
                                                name="soEmail" placeholder="Email" required>
                                        </div>
                                        <div class="form-group">
                                            <input type="password" class="form-control form-control-user"
                                                id="soPassword" name="soPassword" placeholder="Mot de passe" required>
                                        </div>
                                        <button type="submit" class="btn btn-primary btn-user btn-block">
                                            Se connecter
                                        </button>
                                    </form>
                                    <hr>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>

            </div>

        </div>

    </div>

    <!-- Bootstrap core JavaScript-->
    <script src="/vendor/jquery/jquery.min.js"></script>
    <script src="/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>

    <!-- Core plugin JavaScript-->
    <script src="/vendor/jquery-easing/jquery.easing.min.js"></script>

    <!-- Custom scripts for all pages-->
    <script src="/js/sb-admin-2.min.js"></script>

</body>

</html>

Également des modifications sur le template :

  • Gestion du lien actif de navigation
  • Lien d'ajout pour le CRUD
  • Gestion des messages flash
  • Gestion du nom d'utilisateur récupéré dans sa session
  • Gestion du bouton de déconnexion
  • Ajout des dépendances js 
$ diff -u active-offer-koa-v1/views/template.html.ejs active-offer-koa-v2/views/template.html.ejs
--- active-offer-koa-v1/views/template.html.ejs	2020-04-16 04:40:50.685382008 +0200
+++ active-offer-koa-v2/views/template.html.ejs	2020-04-20 06:28:28.461065688 +0200
@@ -9,7 +9,7 @@
     <meta name="description" content="Zenicheck - Gestion de vos offres commerciales">
     <meta name="author" content="Yann Carlen - Zenicheck">
 
-    <title><%= `${ctx.title}-${title}` %></title>
+    <title><%= `${ctx.title} - ${title}` %></title>
 
     <!-- Custom fonts for this template-->
     <link href="/vendor/fontawesome-free/css/all.min.css" rel="stylesheet" type="text/css">
@@ -19,6 +19,11 @@
 
     <!-- Custom styles for this template-->
     <link href="/css/sb-admin-2.min.css" rel="stylesheet">
+    <style>
+        .table-responsive {
+            overflow: inherit;
+        }
+    </style>
 
 </head>
 
@@ -41,9 +46,8 @@
 
             <!-- Divider -->
             <hr class="sidebar-divider my-0">
-
             <!-- Nav Item - Dashboard -->
-            <li class="nav-item active">
+            <li class="nav-item <% if (ctx.request.url.split('?')[0] == '/adm') { %>active<% } %>">
                 <a class="nav-link" href="/adm">
                     <i class="fas fa-fw fa-tachometer-alt"></i>
                     <span>Dashboard</span></a>
@@ -58,11 +62,16 @@
             </div>
 
             <!-- Nav Item - Offers -->
-            <li class="nav-item">
+            <li class="nav-item  <% if (ctx.request.url.indexOf('/adm/offer') != -1) { %>active<% } %>">
                 <a class="nav-link" href="/adm/offer">
                     <i class="fas fa-fw fa-ad"></i>
                     <span>offres</span></a>
             </li>
+            <li class="nav-item  <% if (ctx.request.url.indexOf('/adm/offer/add') != -1) { %>active<% } %>">
+                <a class="nav-link" href="/adm/offer/add">
+                    <i class="fas fa-fw fa-ad"></i>
+                    <span>ajouter une offre</span></a>
+            </li>
 
             <!-- Divider -->
             <hr class="sidebar-divider d-none d-md-block">
@@ -95,7 +104,7 @@
                         <li class="nav-item dropdown no-arrow">
                             <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button"
                                 data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                                <span class="mr-2 d-none d-lg-inline text-gray-600 small">Admin</span>
+                                <span class="mr-2 d-none d-lg-inline text-gray-600 small"><%- ctx.state.user %></span>
                             </a>
                             <!-- Dropdown - User Information -->
                             <div class="dropdown-menu dropdown-menu-right shadow animated--grow-in"
@@ -115,6 +124,16 @@
                 <!-- Begin Page Content -->
                 <div class="container-fluid">
                     <%- body %>
+                    <% var flash = ctx.flash('info') %>
+                    <% flash.forEach(function (e) { %>
+                    <div class="col-lg-3 mb-3 flash" style="margin-top: 50px;">
+                        <div class="card bg-success text-white shadow">
+                            <div class="card-body">
+                                <%- e %>
+                            </div>
+                        </div>
+                    </div>
+                    <% }) %>
                 </div>
                 <!-- /.container-fluid -->
 
@@ -158,7 +177,7 @@
                 <div class="modal-body">Voulez-vous vraiment vous déconnecter ?</div>
                 <div class="modal-footer">
                     <button class="btn btn-secondary" type="button" data-dismiss="modal">Annuler</button>
-                    <a class="btn btn-primary" href="/adm/logout">Se déconnecter</a>
+                    <a class="btn btn-primary" href="/so/logout">Se déconnecter</a>
                 </div>
             </div>
         </div>
@@ -176,6 +195,12 @@
 
     <!-- Page level plugins -->
     <script src="/vendor/chart.js/Chart.min.js"></script>
+    <script src="/vendor/datatables/jquery.dataTables.min.js"></script>
+    <script src="/vendor/datatables/dataTables.bootstrap4.min.js"></script>
+
+    <!-- Custom scripts for all pages-->
+    <script src="/js/app.js"></script>
+
 </body>
 
 </html>
\ No newline at end of file

Le script de gestion des messages flash et de la datatable :

/* public/js/app.js */
// Call the dataTables jQuery plugin
$(document).ready(function () {
    const offerTable = $('#offerTable')
    const flash = $('.flash')

    offerTable.length && offerTable.DataTable();

    flash.length && flash.each(function () {
        $(this).fadeOut(2999);
    });

});

Ce qui va donner les visuels suivants (une fois le router raccordé) :

CRUD :

Authentification :

Ajout/Édition :

Publication/Dépublication :

Intervient maintenant l'injection des différentes dépendances dans le système de middleware de Koa :

/* app.mjs */
import path from 'path'
import Koa from 'koa'
import ejs from 'koa-ejs'
import logger from 'koa-logger'
import _static from 'koa-static'
import session from 'koa-generic-session'
import flash from 'koa-better-flash'
import bodyparser from 'koa-bodyparser'
import passport from 'koa-passport'
import admin from 'firebase-admin'
import dotenv from 'dotenv'

import indexRouter from './routes/index.mjs'
import admRouter from './routes/adm.mjs'
import soRouter from './routes/so.mjs'

const __dirname = path.resolve()

const config = dotenv.config().parsed

import serviceAccount from './offer-firebase.json'


admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: config.FIREBASE_HOST
})

const db = admin.firestore()

const app = new Koa()

app.context.db = db
app.context.config = config
app.context.title = 'Gestion de vos offres commerciales'

app.keys = [config.KEYS_1, config.KEYS_2]

ejs(app, {
  root: path.join(__dirname, 'views'),
  layout: 'template',
  viewExt: 'html.ejs',
  cache: false, //TODO turn on for production
  debug: false, //TODO turn off for profuction
})

app.use(bodyparser())

app.use(session({
  prefix: '__sess:',
  key: '__sid'
}))

import './helpers/so.mjs'
app.use(passport.initialize())
app.use(passport.session())

app.use(logger())

app.use(flash())

app.use(_static(path.join(__dirname, 'public')))

app.use(indexRouter.middleware())
app.use(admRouter.middleware())
app.use(soRouter.middleware())


export default app

Arrive ensuite l'implémentation de l'authentification avec Passport et Koa :

Implémentation de la stratégie d'authentification locale :

/* helpers/so.mjs */
import passport from 'koa-passport'
import bcrypt from 'bcrypt'
import strategies from 'passport-local'
import dotenv from 'dotenv'

const config = dotenv.config().parsed

passport.serializeUser((user, done) => done(null, user))

passport.deserializeUser((id, done) => done(null, id))

const LocalStrategy = strategies.Strategy

const local = new LocalStrategy({
    usernameField: 'soEmail',
    passwordField: 'soPassword'
}, async (username, password, done) => config.USERNAME === username ?
    bcrypt.compare(password, config.PASSWORD, (_, response) => done(null, response ? config.USERNAME : false))
    :
    done(null, false))

passport.use(local)

Le middleware d'autorisation :

/* helpers/middleware.mjs */

const authenticated = () => (ctx, next) => ctx.isAuthenticated() ? next() : ctx.redirect('/so')

export { authenticated }

Et enfin le routeur gérant les différentes routes propres à l'authentification :

/* routes/so.mjs */
import Router from 'koa-joi-router'
import passport from 'koa-passport'

const Joi = Router.Joi

const router = Router()

const joiDefault = {
    type: 'form',
    continueOnError: true
}

router.prefix('/so')

router.get('/', async ctx => {
    if (ctx.isAuthenticated()) {
        await ctx.flash('info', 'Vous êtes bien connecté !')
        return await ctx.redirect('/adm')
    }
    await ctx.render('so', { ctx, title: 'Se connecter', layout: false })
})

router.post('/', {
    validate: {
        body: {
            soEmail: Joi.string().email().required()
                .error(new Error('Veuillez entrez votre email')),
            soPassword: Joi.string().required()
                .error(new Error('Veuillez entrez votre mot de passe'))
        },
        ...joiDefault
    }
}, async ctx => {
    if (ctx.isAuthenticated()) {
        await ctx.flash('info', 'Vous êtes bien connecté !')
        return await ctx.redirect('/adm')
    }
    if (ctx.invalid) {
        return await ctx.render('so', { ctx, title: 'Se connecter', errors: ctx.invalid.body, layout: false })
    }
    return passport.authenticate('local', async (err, user) => {
        console.log(user)
        if (user === false) {
            ctx.status = 401
            const errors = { msg: 'Email ou Mot de Passe invalide' }
            return await ctx.render('so', { ctx, title: 'Se connecter', errors, layout: false })
        }
        ctx.login(user)
        await ctx.flash('info', 'Vous êtes bien connecté !')
        return await ctx.redirect('/adm')
    })(ctx)
})

router.get('/logout', async ctx => {
    await ctx.logout()
    await ctx.redirect('/so')
})

export default router

Et enfin l'implémentation du CRUD des offres avec Firestore comme outils de persistance :

La couche d'accès aux données :

/* helpers/offerRepository.mjs */
const tableName = 'offer'

const schema = ({ offerTitle, offerLink, offerPublished }) =>
    ({ offerTitle, offerLink, offerPublished: offerPublished == undefined ? false : offerPublished })

const saveOffer = db => offer => db.collection(tableName).doc().set(schema(offer))

const editOffer = db => id => offer => db.collection(tableName).doc(id).set(schema(offer))

const deleteOffer = db => id => (db.collection(tableName).doc(id)).delete()

const getOffer = db => (db.collection(tableName).get()).then(e => e.docs.map(doc => ({ id: doc.id, ...doc.data()})))

const findOffer = db => id => (db.collection(tableName).doc(id).get()).then(e => [ e.exists, e ])

export { saveOffer, getOffer, findOffer, editOffer, deleteOffer }

Le routeur associé :

/* routes/adm.mjs */
import Router from 'koa-joi-router'
import { authenticated } from '../helpers/middleware.mjs'
import { saveOffer, getOffer, findOffer, editOffer, deleteOffer } from '../helpers/offerRepository.mjs'

const Joi = Router.Joi

const router = Router()

const joiDefault = {
    type: 'form',
    continueOnError: true
}

router.prefix('/adm')

router.param('id', async (id, ctx, next) => {
    const [offer, e] = await findOffer(ctx.db)(id)
    if (!offer) return ctx.status = 404
    ctx.offer = { id: e.id, ...e.data() }
    ctx.request.body = ctx.offer
    await next()
})

router.get('/', authenticated(), async ctx => await ctx.render('adm', { ctx, title: 'Adm' }))

router.get('/offer', authenticated(), async ctx => {
    await ctx.render('adm-offer', { ctx, title: 'Listes des offres', offers: await getOffer(ctx.db) })
})

router.get('/offer/add', authenticated(), async ctx => await ctx.render('adm-offer-action', { ctx, title: 'Ajouter une offre' }))

router.get('/offer/edit/:id', authenticated(), async ctx => await ctx.render('adm-offer-action', { ctx, title: 'Editer l\'offre', offer: ctx.offer }))

router.get('/offer/delete/:id', authenticated(), async ctx => await ctx.render('adm-offer-action', { ctx, title: 'Editer l\'offre', offer: ctx.offer, delete: true }))

router.get('/offer/publish/:id', authenticated(), async ctx => await ctx.render('adm-offer-action', { ctx, title: 'Publier l\'offre', offer: ctx.offer, publish: true }))

router.post('/offer/store', {
    validate: {
        body: {
            offerTitle: Joi.string().max(255).min(4).required()
                .error(new Error('Veuillez entrer une titre entre 4 et 255 charactères')),
            offerLink: Joi.string().uri().required()
                .error(new Error('Veuillez entrer une lien')),
            offerId: Joi.string()
        },
        ...joiDefault
    }
}, authenticated(), async ctx => {
    if (ctx.request.body.offerId) {
        const [valid, e] = await findOffer(ctx.db)(ctx.request.body.offerId)
        if (!valid) return ctx.status = 404
        const offer = { id: e.id, ...e.data() }
        if (ctx.invalid) {
            return await ctx.render('adm-offer-action', { ctx, errors: ctx.invalid.body, title: 'Editer une offre', offer })
        }
        await editOffer(ctx.db)(offer.id)({ ...ctx.request.body, offerPublished: offer.offerPublished })
        ctx.flash('info', 'Offre modifiée')
        return ctx.redirect('/adm/offer')
    }
    if (ctx.invalid) {
        return await ctx.render('adm-offer-action', { ctx, errors: ctx.invalid.body, title: 'Ajouter une offre' })
    }
    await saveOffer(ctx.db)(ctx.request.body)
    ctx.flash('info', 'Offre créée')
    ctx.redirect('/adm/offer')
})

router.post('/offer/unstore', {
    validate: {
        body: {
            offerId: Joi.string().required()
        },
        ...joiDefault
    }
}, authenticated(), async ctx => {
    if (ctx.invalid) {
        return ctx.redirect('/adm/offer')
    }
    const [valid, _] = await findOffer(ctx.db)(ctx.request.body.offerId)
    if (!valid) return ctx.status = 404
    await deleteOffer(ctx.db)(ctx.request.body.offerId)
    ctx.flash('info', 'Offre supprimée')
    ctx.redirect('/adm/offer')
})

router.post('/offer/release', {
    validate: {
        body: {
            offerId: Joi.string().required()
        },
        ...joiDefault
    }
}, authenticated(), async ctx => {
    if (ctx.invalid) {
        return ctx.redirect('/adm/offer')
    }
    const [valid, e] = await findOffer(ctx.db)(ctx.request.body.offerId)
    if (!valid) return ctx.status = 404
    const offer = { ...e.data() }
    await editOffer(ctx.db)(ctx.request.body.offerId)({ ...offer, offerPublished: !offer.offerPublished })
    if (offer.offerPublished) {
        ctx.flash('info', 'Offre dépubliée')
    } else {
        ctx.flash('info', 'Offre publiée')
    }
    ctx.redirect('/adm/offer')
})

export default router

Pour rappel la commande pour lancer le projet durant le développement est la suivante :

$ PORT=8900 npx nodemon --experimental-modules --experimental-json-modules bin/www.mjs --watch routes --watch views --watch helpers --watch app.mjs

Les sources de ce chapitre d'implémentation : https://github.com/Zenicheck/blog/tree/master/active-offer-koa-v2

Dans la partie prochaine interviendra l'implémentation des fonctionnalités suivantes :

  • API REST pour l'accès public des offres
  • Exemple d'implémentation d'une iframe avec personnalisation du visuel pour l'API REST
  • Exemple d'utilisation de l'API sur un site de référence
  • Déploiement de la plateforme avec Docker 

L'offre du moment :

N'hésitez pas à me contacter pour demander un devis, discuter d'un projet et surtout profitez de notre offre du moment : 1 an d'hébergement de site internet offert à la signature d'un contrat !  → En savoir plus