From 7496c3108f69ab7535261d4182711736ce7c4d24 Mon Sep 17 00:00:00 2001
From: vmaubert <v.maubert@code-troopers.com>
Date: Wed, 22 Sep 2021 16:23:18 +0200
Subject: [PATCH] =?UTF-8?q?feat(logs):=20ajoute=20les=20logs=20sur=20les?=
 =?UTF-8?q?=20diff=C3=A9rentes=20effectu=C3=A9es=20sur=20les=20titres=20(#?=
 =?UTF-8?q?798)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 package-lock.json                             | 152 +++++++++++++++++-
 package.json                                  |   1 +
 src/api/graphql/resolvers/titre-demande.ts    |   2 +-
 src/api/graphql/resolvers/titres-etapes.ts    |   8 +-
 src/api/graphql/schemas/logs.graphql          |  23 +++
 src/api/graphql/schemas/points.graphql        |   2 +
 src/api/graphql/schemas/titres-etapes.graphql |   4 +
 .../titres-etapes-heritage-contenu-update.ts  |  12 +-
 .../titres-etapes-heritage-props-update.ts    |   2 +-
 .../processes/titres-etapes-ordre-update.ts   |   6 +-
 .../utils/titre-slug-and-relations-update.ts  |  11 +-
 src/database/models/logs.ts                   |  44 +++++
 src/database/models/titres-etapes.ts          |   8 +
 src/database/queries/logs.ts                  | 110 +++++++++++++
 src/database/queries/permissions/logs.ts      |  28 ++++
 .../queries/permissions/titres-etapes.ts      |   6 +
 src/database/queries/titres-etapes.ts         |  57 +++++--
 src/knex/migrations/20210915144021_logs.ts    |  16 ++
 src/types.ts                                  |  12 +-
 tests/titres-demarches.test.ts                |  18 ++-
 tests/titres-etapes-modifier.test.ts          |  18 ++-
 tsconfig.json                                 |   2 +-
 22 files changed, 497 insertions(+), 45 deletions(-)
 create mode 100644 src/api/graphql/schemas/logs.graphql
 create mode 100644 src/database/models/logs.ts
 create mode 100644 src/database/queries/logs.ts
 create mode 100644 src/database/queries/permissions/logs.ts
 create mode 100644 src/knex/migrations/20210915144021_logs.ts

diff --git a/package-lock.json b/package-lock.json
index a70dc393a..7b47346a7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,7 +5,7 @@
   "requires": true,
   "packages": {
     "": {
-      "version": "0.24.97",
+      "version": "0.25.0",
       "license": "AGPL-3.0-or-later",
       "dependencies": {
         "@graphql-tools/graphql-file-loader": "^6.2.7",
@@ -59,6 +59,7 @@
         "graphql-type-json": "^0.3.2",
         "graphql-upload": "^12.0.0",
         "html-to-text": "^8.0.0",
+        "jsondiffpatch": "^0.4.1",
         "jsonwebtoken": "^8.5.1",
         "knex": "0.21.19",
         "knex-db-manager": "^0.7.0",
@@ -5846,6 +5847,11 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/diff-match-patch": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
+      "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="
+    },
     "node_modules/diff-sequences": {
       "version": "26.6.2",
       "license": "MIT",
@@ -10920,6 +10926,85 @@
         "node": ">=6"
       }
     },
+    "node_modules/jsondiffpatch": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.4.1.tgz",
+      "integrity": "sha512-t0etAxTUk1w5MYdNOkZBZ8rvYYN5iL+2dHCCx/DpkFm/bW28M6y5nUS83D4XdZiHy35Fpaw6LBb+F88fHZnVCw==",
+      "dependencies": {
+        "chalk": "^2.3.0",
+        "diff-match-patch": "^1.0.0"
+      },
+      "bin": {
+        "jsondiffpatch": "bin/jsondiffpatch"
+      },
+      "engines": {
+        "node": ">=8.17.0"
+      }
+    },
+    "node_modules/jsondiffpatch/node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/jsondiffpatch/node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/jsondiffpatch/node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/jsondiffpatch/node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
+    },
+    "node_modules/jsondiffpatch/node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/jsondiffpatch/node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/jsondiffpatch/node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/jsonfile": {
       "version": "4.0.0",
       "license": "MIT",
@@ -22485,6 +22570,11 @@
       "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
       "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="
     },
+    "diff-match-patch": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
+      "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="
+    },
     "diff-sequences": {
       "version": "26.6.2"
     },
@@ -25930,6 +26020,66 @@
         "minimist": "^1.2.5"
       }
     },
+    "jsondiffpatch": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.4.1.tgz",
+      "integrity": "sha512-t0etAxTUk1w5MYdNOkZBZ8rvYYN5iL+2dHCCx/DpkFm/bW28M6y5nUS83D4XdZiHy35Fpaw6LBb+F88fHZnVCw==",
+      "requires": {
+        "chalk": "^2.3.0",
+        "diff-match-patch": "^1.0.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "3.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+          "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+          "requires": {
+            "color-convert": "^1.9.0"
+          }
+        },
+        "chalk": {
+          "version": "2.4.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+          "requires": {
+            "ansi-styles": "^3.2.1",
+            "escape-string-regexp": "^1.0.5",
+            "supports-color": "^5.3.0"
+          }
+        },
+        "color-convert": {
+          "version": "1.9.3",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+          "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+          "requires": {
+            "color-name": "1.1.3"
+          }
+        },
+        "color-name": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+          "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
+        },
+        "escape-string-regexp": {
+          "version": "1.0.5",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+          "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
+        },
+        "has-flag": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+          "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
+        },
+        "supports-color": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
     "jsonfile": {
       "version": "4.0.0",
       "requires": {
diff --git a/package.json b/package.json
index 8e1713564..12e882d71 100644
--- a/package.json
+++ b/package.json
@@ -111,6 +111,7 @@
     "graphql-type-json": "^0.3.2",
     "graphql-upload": "^12.0.0",
     "html-to-text": "^8.0.0",
+    "jsondiffpatch": "^0.4.1",
     "jsonwebtoken": "^8.5.1",
     "knex": "0.21.19",
     "knex-db-manager": "^0.7.0",
diff --git a/src/api/graphql/resolvers/titre-demande.ts b/src/api/graphql/resolvers/titre-demande.ts
index 569b078d3..35c52443a 100644
--- a/src/api/graphql/resolvers/titre-demande.ts
+++ b/src/api/graphql/resolvers/titre-demande.ts
@@ -119,7 +119,7 @@ const titreDemandeCreer = async (
       titulaires: [{ id: titreDemande.entrepriseId }]
     } as ITitreEtape
 
-    titreEtape = await titreEtapeUpsert(titreEtape)
+    titreEtape = await titreEtapeUpsert(titreEtape, user)
 
     await titreEtapeUpdateTask(titreEtape.id, titreEtape.titreDemarcheId)
 
diff --git a/src/api/graphql/resolvers/titres-etapes.ts b/src/api/graphql/resolvers/titres-etapes.ts
index 3c7c75914..ba21c902a 100644
--- a/src/api/graphql/resolvers/titres-etapes.ts
+++ b/src/api/graphql/resolvers/titres-etapes.ts
@@ -305,7 +305,7 @@ const etapeCreer = async (
     )
     etape.contenu = contenu
 
-    const etapeUpdated = await titreEtapeUpsert(etape)
+    const etapeUpdated = await titreEtapeUpsert(etape, user!)
 
     await contenuElementFilesCreate(newFiles, 'demarches', etapeUpdated.id)
 
@@ -438,7 +438,7 @@ const etapeModifier = async (
     )
     etape.contenu = contenu
 
-    const etapeUpdated = await titreEtapeUpsert(etape)
+    const etapeUpdated = await titreEtapeUpsert(etape, user!)
 
     await contenuElementFilesCreate(newFiles, 'demarches', etapeUpdated.id)
 
@@ -515,7 +515,7 @@ const etapeDeposer = async (
 
     const statutIdAndDate = statutIdAndDateGet(titreEtape, user!, true)
 
-    await titreEtapeUpdate(titreEtape.id, statutIdAndDate)
+    await titreEtapeUpdate(titreEtape.id, statutIdAndDate, user!)
     const etapeUpdated = await titreEtapeGet(
       titreEtape.id,
       {
@@ -595,7 +595,7 @@ const etapeSupprimer = async (
       throw new Error(rulesErrors.join(', '))
     }
 
-    await titreEtapeDelete(id)
+    await titreEtapeDelete(id, user!)
 
     await fichiersRepertoireDelete(id, 'demarches')
 
diff --git a/src/api/graphql/schemas/logs.graphql b/src/api/graphql/schemas/logs.graphql
new file mode 100644
index 000000000..ac56e6b06
--- /dev/null
+++ b/src/api/graphql/schemas/logs.graphql
@@ -0,0 +1,23 @@
+# import * from 'scalars.graphql'
+# import * from 'utilisateurs.graphql'
+
+enum Operation {
+  create
+  update
+  delete
+}
+
+type Log {
+  """
+  Id unique
+  """
+  id: ID!
+
+  utilisateur: Utilisateur
+
+  date: String
+
+  operation: Operation
+
+  differences: Json
+}
diff --git a/src/api/graphql/schemas/points.graphql b/src/api/graphql/schemas/points.graphql
index be9ceca22..7c1ba4e08 100644
--- a/src/api/graphql/schemas/points.graphql
+++ b/src/api/graphql/schemas/points.graphql
@@ -78,6 +78,7 @@ type PointReference {
 }
 
 input InputPoint {
+  id: ID
   groupe: Int!
   contour: Int!
   point: Int!
@@ -90,6 +91,7 @@ input InputPoint {
 }
 
 input InputPointReference {
+  id: ID
   geoSystemeId: ID!
   coordonnees: InputCoordonnees!
   opposable: Boolean
diff --git a/src/api/graphql/schemas/titres-etapes.graphql b/src/api/graphql/schemas/titres-etapes.graphql
index 28a846c25..6f5f55664 100644
--- a/src/api/graphql/schemas/titres-etapes.graphql
+++ b/src/api/graphql/schemas/titres-etapes.graphql
@@ -7,6 +7,7 @@
 # import * from 'territoires.graphql'
 # import * from 'documents.graphql'
 # import * from 'titres-demarches.graphql'
+# import * from 'logs.graphql'
 
 "Étape d'une démarche effectuée sur un titre minier"
 type Etape {
@@ -82,6 +83,9 @@ type Etape {
   "Justificatifs d'entreprises relatifs à l'étape"
   justificatifs: [Document]
 
+  "Logs de l’étape"
+  logs: [Log]
+
   incertitudes: Incertitudes
 
   heritageProps: HeritageProps
diff --git a/src/business/processes/titres-etapes-heritage-contenu-update.ts b/src/business/processes/titres-etapes-heritage-contenu-update.ts
index 6fbbe3ebf..4c6e47fc3 100644
--- a/src/business/processes/titres-etapes-heritage-contenu-update.ts
+++ b/src/business/processes/titres-etapes-heritage-contenu-update.ts
@@ -56,10 +56,14 @@ const titresEtapesHeritageContenuUpdate = async (
 
           if (hasChanged) {
             queue.add(async () => {
-              await titreEtapeUpdate(titreEtape.id, {
-                contenu,
-                heritageContenu
-              })
+              await titreEtapeUpdate(
+                titreEtape.id,
+                {
+                  contenu,
+                  heritageContenu
+                },
+                userSuper
+              )
 
               const log = {
                 type: 'titre / démarche / étape : héritage du contenu (mise à jour) ->',
diff --git a/src/business/processes/titres-etapes-heritage-props-update.ts b/src/business/processes/titres-etapes-heritage-props-update.ts
index fa9cacb5f..89ff2ed8c 100644
--- a/src/business/processes/titres-etapes-heritage-props-update.ts
+++ b/src/business/processes/titres-etapes-heritage-props-update.ts
@@ -50,7 +50,7 @@ const titresEtapesHeritagePropsUpdate = async (
 
         if (hasChanged) {
           queue.add(async () => {
-            await titreEtapeUpsert(newTitreEtape)
+            await titreEtapeUpsert(newTitreEtape, userSuper)
 
             const log = {
               type: 'titre / démarche / étape : héritage des propriétés (mise à jour) ->',
diff --git a/src/business/processes/titres-etapes-ordre-update.ts b/src/business/processes/titres-etapes-ordre-update.ts
index 3e8da9e55..9d0b1f5c7 100644
--- a/src/business/processes/titres-etapes-ordre-update.ts
+++ b/src/business/processes/titres-etapes-ordre-update.ts
@@ -36,7 +36,11 @@ const titresEtapesOrdreUpdate = async (titresDemarchesIds?: string[]) => {
       ).forEach((titreEtape: ITitreEtape, index: number) => {
         if (titreEtape.ordre !== index + 1) {
           queue.add(async () => {
-            await titreEtapeUpdate(titreEtape.id, { ordre: index + 1 })
+            await titreEtapeUpdate(
+              titreEtape.id,
+              { ordre: index + 1 },
+              userSuper
+            )
 
             const log = {
               type: 'titre / démarche / étape : ordre (mise à jour) ->',
diff --git a/src/business/utils/titre-slug-and-relations-update.ts b/src/business/utils/titre-slug-and-relations-update.ts
index 037e67cb3..060981a00 100644
--- a/src/business/utils/titre-slug-and-relations-update.ts
+++ b/src/business/utils/titre-slug-and-relations-update.ts
@@ -8,7 +8,8 @@ import {
   ITitreEtape,
   ITitrePoint,
   ITitrePointReference,
-  ITitreTravaux
+  ITitreTravaux,
+  IUtilisateur
 } from '../../types'
 
 import titreDemarcheOrTravauxSortAsc from './titre-elements-sort-asc'
@@ -108,7 +109,11 @@ const titreActiviteSlugFind = (titreActivite: ITitreActivite, titre: ITitre) =>
 interface ITitreRelation {
   name: string
   slugFind: (...args: any[]) => string
-  update: (id: string, element: { slug: string }) => Promise<any>
+  update: (
+    id: string,
+    element: { slug: string },
+    user: IUtilisateur
+  ) => Promise<any>
   relations?: ITitreRelation[]
 }
 
@@ -167,7 +172,7 @@ const relationsSlugsUpdate = async (
     for (const element of parent[relation.name]) {
       const slug = relation.slugFind(element, parent)
       if (slug !== element.slug) {
-        await relation.update(element.id, { slug })
+        await relation.update(element.id, { slug }, userSuper)
         hasChanged = true
       }
       if (relation.relations) {
diff --git a/src/database/models/logs.ts b/src/database/models/logs.ts
new file mode 100644
index 000000000..9d2cedb05
--- /dev/null
+++ b/src/database/models/logs.ts
@@ -0,0 +1,44 @@
+import { Model } from 'objection'
+
+import { ILog } from '../../types'
+import { idGenerate } from './_format/id-create'
+import { join } from 'path'
+
+interface Logs extends ILog {}
+
+class Logs extends Model {
+  public static tableName = 'logs'
+
+  public static jsonSchema = {
+    type: 'object',
+
+    properties: {
+      id: { type: 'string' },
+      utilisateurId: { type: 'string' },
+      date: { type: 'date' },
+      elementId: { type: 'string' },
+      operation: { enum: ['create', 'update', 'delete'] },
+      differences: { type: 'json' }
+    }
+  }
+
+  public static relationMappings = {
+    utilisateur: {
+      relation: Model.BelongsToOneRelation,
+      modelClass: join(__dirname, 'utilisateurs'),
+      join: {
+        from: 'logs.utilisateurId',
+        to: 'utilisateurs.id'
+      }
+    }
+  }
+
+  async $beforeInsert(queryContext: any) {
+    await super.$beforeInsert(queryContext)
+
+    this.id = idGenerate()
+    this.date = new Date()
+  }
+}
+
+export default Logs
diff --git a/src/database/models/titres-etapes.ts b/src/database/models/titres-etapes.ts
index 598c57b07..ed777d976 100644
--- a/src/database/models/titres-etapes.ts
+++ b/src/database/models/titres-etapes.ts
@@ -180,6 +180,14 @@ class TitresEtapes extends Model {
         },
         to: 'forets.id'
       }
+    },
+    logs: {
+      relation: Model.HasManyRelation,
+      modelClass: join(__dirname, 'logs'),
+      join: {
+        from: 'titresEtapes.id',
+        to: 'logs.elementId'
+      }
     }
   }
 
diff --git a/src/database/queries/logs.ts b/src/database/queries/logs.ts
new file mode 100644
index 000000000..73b0bb0c9
--- /dev/null
+++ b/src/database/queries/logs.ts
@@ -0,0 +1,110 @@
+import Logs from '../models/logs'
+import { create } from 'jsondiffpatch'
+import {
+  Model,
+  PartialModelGraph,
+  RelationExpression,
+  UpsertGraphOptions
+} from 'objection'
+
+const diffPatcher = create({
+  // on filtre certaines proprietés qu’on ne souhaite pas voir apparaitre dans les logs
+  propertyFilter: (name: string) => !['slug', 'ordre'].includes(name)
+})
+
+export const deleteLogCreate = async (id: string, userId: string) => {
+  await Logs.query().insert({
+    elementId: id,
+    operation: 'delete',
+    utilisateurId: userId
+  })
+}
+
+export const createLogCreate = async (id: string, userId: string) => {
+  await Logs.query().insert({
+    elementId: id,
+    operation: 'create',
+    utilisateurId: userId
+  })
+}
+
+export const patchLogCreate = async <T extends Model>(
+  id: string,
+  partialEntity: Partial<T>,
+  model: typeof Model,
+  userId: string
+) => {
+  const oldValue = await model.query().findById(id)
+
+  const oldPartialValue = (
+    Object.keys(partialEntity) as Array<keyof Model>
+  ).reduce((result, key) => {
+    result[key] = oldValue[key]
+
+    return result
+  }, {} as any)
+
+  const result = await model.query().patchAndFetchById(id, {
+    ...partialEntity,
+    id
+  })
+
+  const differences = diffPatcher.diff(oldPartialValue, partialEntity)
+
+  if (differences) {
+    await Logs.query().insert({
+      elementId: id,
+      utilisateurId: userId,
+      operation: 'update',
+      differences
+    })
+  }
+
+  return result
+}
+
+export const upsertLogCreate = async <T extends Model>(
+  id: string,
+  entity: PartialModelGraph<T>,
+  model: typeof Model,
+  options: UpsertGraphOptions,
+  relations: RelationExpression<T>,
+  userId: string
+): Promise<T> => {
+  const oldValue = id
+    ? await model
+        .query()
+        .findById(id)
+        .withGraphFetched(relations)
+        .returning('*')
+    : undefined
+
+  const newValue = await model
+    .query()
+    .upsertGraph(entity, options)
+    .withGraphFetched(relations)
+    .returning('*')
+
+  let differences: any
+  let operation: 'create' | 'update' = 'create'
+
+  if (oldValue) {
+    differences = diffPatcher.diff(oldValue, newValue)
+
+    // si il n’y a pas de différences, alors on ne log plus cette modification
+    if (!differences || !Object.keys(differences).length) {
+      return newValue as T
+    }
+    operation = 'update'
+  }
+
+  await Logs.query().insert({
+    elementId: (newValue as any).id,
+    utilisateurId: userId,
+    date: new Date(),
+    operation,
+    differences
+  })
+
+  return newValue as T
+}
diff --git a/src/database/queries/permissions/logs.ts b/src/database/queries/permissions/logs.ts
new file mode 100644
index 000000000..287fe112d
--- /dev/null
+++ b/src/database/queries/permissions/logs.ts
@@ -0,0 +1,28 @@
+import { QueryBuilder } from 'objection'
+
+import { IUtilisateur } from '../../../types'
+
+import { permissionCheck } from '../../../tools/permission'
+
+import Logs from '../../models/logs'
+import { utilisateursQueryModify } from './utilisateurs'
+import Utilisateurs from '../../models/utilisateurs'
+
+export const logsQueryModify = (
+  q: QueryBuilder<Logs, Logs | Logs[]>,
+  user: IUtilisateur | null
+) => {
+  q.select('logs.*')
+
+  // Les logs sont uniquement visibles par les super
+  if (!user || !permissionCheck(user.permissionId, ['super'])) {
+    q.where(false)
+  }
+
+  q.modifyGraph('utilisateur', b => {
+    utilisateursQueryModify(
+      b as QueryBuilder<Utilisateurs, Utilisateurs | Utilisateurs[]>,
+      user
+    )
+  })
+}
diff --git a/src/database/queries/permissions/titres-etapes.ts b/src/database/queries/permissions/titres-etapes.ts
index 48109aa83..a75f44ba9 100644
--- a/src/database/queries/permissions/titres-etapes.ts
+++ b/src/database/queries/permissions/titres-etapes.ts
@@ -20,6 +20,8 @@ import {
 import { entreprisesQueryModify, entreprisesTitresQuery } from './entreprises'
 import { titresDemarchesQueryModify } from './titres-demarches'
 import TitresDemarches from '../../models/titres-demarches'
+import Logs from '../../models/logs'
+import { logsQueryModify } from './logs'
 
 const titreEtapeModificationQueryBuild = (user: IUtilisateur | null) => {
   if (permissionCheck(user?.permissionId, ['super'])) {
@@ -199,6 +201,10 @@ const titresEtapesQueryModify = (
     ).select('titresAmodiataires.operateur')
   })
 
+  q.modifyGraph('logs', b => {
+    logsQueryModify(b as QueryBuilder<Logs, Logs | Logs[]>, user)
+  })
+
   return q
 }
 
diff --git a/src/database/queries/titres-etapes.ts b/src/database/queries/titres-etapes.ts
index 8363f7b53..665c2f33b 100644
--- a/src/database/queries/titres-etapes.ts
+++ b/src/database/queries/titres-etapes.ts
@@ -19,6 +19,12 @@ import TitresEtapesJustificatifs from '../models/titres-etapes-justificatifs'
 import TitresAdministrationsLocales from '../models/titres-administrations-locales'
 import TitresForets from '../models/titres-forets'
 import { titresEtapesQueryModify } from './permissions/titres-etapes'
+import {
+  createLogCreate,
+  deleteLogCreate,
+  patchLogCreate,
+  upsertLogCreate
+} from './logs'
 
 const titresEtapesQueryBuild = (
   { fields }: { fields?: IFields },
@@ -88,25 +94,48 @@ const titresEtapesGet = async (
   return q
 }
 
-const titreEtapeCreate = async (titreEtape: ITitreEtape) =>
-  TitresEtapes.query()
+const titreEtapeCreate = async (
+  titreEtape: ITitreEtape,
+  user: IUtilisateur
+) => {
+  const newValue = await TitresEtapes.query()
     .insertAndFetch(titreEtape)
     .withGraphFetched(options.titresEtapes.graph)
 
-const titreEtapeUpdate = async (id: string, titreEtape: Partial<ITitreEtape>) =>
-  TitresEtapes.query().patchAndFetchById(id, { ...titreEtape, id })
+  await createLogCreate(titreEtape.id, user.id)
 
-const titreEtapeDelete = async (id: string, trx?: Transaction) =>
-  TitresEtapes.query(trx)
-    .deleteById(id)
-    .withGraphFetched(options.titresEtapes.graph)
-    .returning('*')
+  return newValue
+}
 
-const titreEtapeUpsert = async (titreEtape: ITitreEtape, trx?: Transaction) =>
-  TitresEtapes.query(trx)
-    .upsertGraph(titreEtape, options.titresEtapes.update)
-    .withGraphFetched(options.titresEtapes.graph)
-    .returning('*')
+const titreEtapeUpdate = async (
+  id: string,
+  titreEtape: Partial<ITitreEtape>,
+  user: IUtilisateur
+) => {
+  return patchLogCreate<TitresEtapes>(id, titreEtape, TitresEtapes, user.id)
+}
+
+const titreEtapeDelete = async (
+  id: string,
+  user: IUtilisateur,
+  trx?: Transaction
+) => {
+  const result = await TitresEtapes.query(trx).delete().where('id', id)
+
+  await deleteLogCreate(id, user.id)
+
+  return result
+}
+
+const titreEtapeUpsert = async (titreEtape: ITitreEtape, user: IUtilisateur) =>
+  upsertLogCreate<TitresEtapes>(
+    titreEtape.id,
+    titreEtape,
+    TitresEtapes,
+    options.titresEtapes.update,
+    options.titresEtapes.graph,
+    user.id
+  )
 
 const titresEtapesCommunesGet = async () => TitresCommunes.query()
 
diff --git a/src/knex/migrations/20210915144021_logs.ts b/src/knex/migrations/20210915144021_logs.ts
new file mode 100644
index 000000000..5529f5701
--- /dev/null
+++ b/src/knex/migrations/20210915144021_logs.ts
@@ -0,0 +1,16 @@
+import Knex from 'knex'
+
+export const up = async (knex: Knex): Promise<void> => {
+  await knex.schema.createTable('logs', table => {
+    table.string('id').primary()
+    table.string('utilisateurId').index().notNullable()
+    table.dateTime('date').notNullable()
+    table.string('elementId').notNullable()
+    table.enum('operation', ['create', 'update', 'delete']).notNullable()
+    table.jsonb('differences').nullable()
+  })
+}
+
+export const down = async (knex: Knex): Promise<void> => {
+  await knex.schema.dropTable('logs')
+}
diff --git a/src/types.ts b/src/types.ts
index 3933222f0..709e2e58a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1023,6 +1023,15 @@ interface ITitreDemande {
   references?: ITitreReference[]
 }
 
+interface ILog {
+  id: string
+  utilisateurId: string
+  date: Date
+  elementId: string
+  operation: 'create' | 'update' | 'delete'
+  differences: any
+}
+
 export {
   Index,
   IFields,
@@ -1143,5 +1152,6 @@ export {
   ICache,
   ICacheId,
   IActiviteTypePays,
-  ITitreDemande
+  ITitreDemande,
+  ILog
 }
diff --git a/tests/titres-demarches.test.ts b/tests/titres-demarches.test.ts
index da8225043..0127beb69 100644
--- a/tests/titres-demarches.test.ts
+++ b/tests/titres-demarches.test.ts
@@ -3,6 +3,7 @@ import { graphQLCall, queryImport } from './_utils/index'
 import { titreCreate } from '../src/database/queries/titres'
 import { administrations } from './__mocks__/administrations'
 import { titreEtapeUpsert } from '../src/database/queries/titres-etapes'
+import { userSuper } from '../src/database/user-super'
 
 console.info = jest.fn()
 console.error = jest.fn()
@@ -225,13 +226,16 @@ describe('demarcheModifier', () => {
   test('ne peut pas modifier le type d’une démarche si elle a au moins une étape', async () => {
     const { demarcheId, titreId } = await demarcheCreate()
 
-    await titreEtapeUpsert({
-      id: `${demarcheId}-mno01`,
-      typeId: 'mno',
-      titreDemarcheId: demarcheId,
-      statutId: 'acc',
-      date: '2020-01-01'
-    })
+    await titreEtapeUpsert(
+      {
+        id: `${demarcheId}-mno01`,
+        typeId: 'mno',
+        titreDemarcheId: demarcheId,
+        statutId: 'acc',
+        date: '2020-01-01'
+      },
+      userSuper
+    )
 
     const res = await graphQLCall(
       demarcheModifierQuery,
diff --git a/tests/titres-etapes-modifier.test.ts b/tests/titres-etapes-modifier.test.ts
index 9631298f6..a5780e612 100644
--- a/tests/titres-etapes-modifier.test.ts
+++ b/tests/titres-etapes-modifier.test.ts
@@ -7,6 +7,7 @@ import { titreCreate } from '../src/database/queries/titres'
 import { titreEtapeCreate } from '../src/database/queries/titres-etapes'
 import { titreEtapePropsIds } from '../src/business/utils/titre-etape-heritage-props-find'
 import Titres from '../src/database/models/titres'
+import { userSuper } from '../src/database/user-super'
 
 jest.mock('../src/tools/dir-create', () => ({
   __esModule: true,
@@ -63,13 +64,16 @@ async function etapeCreate() {
   })
 
   const titreEtapeId = 'etape-test-id'
-  await titreEtapeCreate({
-    id: titreEtapeId,
-    typeId: 'mfr',
-    statutId: 'fai',
-    titreDemarcheId,
-    date: ''
-  })
+  await titreEtapeCreate(
+    {
+      id: titreEtapeId,
+      typeId: 'mfr',
+      statutId: 'fai',
+      titreDemarcheId,
+      date: ''
+    },
+    userSuper
+  )
 
   return { titreDemarcheId, titreEtapeId }
 }
diff --git a/tsconfig.json b/tsconfig.json
index cb012635a..c12b039b3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,7 +5,7 @@
     "alwaysStrict": true,
     "esModuleInterop": true,
     "inlineSources": true,
-    "lib": ["es2020"],
+    "lib": ["es2020", "dom"],
     "module": "commonjs",
     "moduleResolution": "node",
     "noFallthroughCasesInSwitch": true,
-- 
GitLab