WIP: Application de gestion du Centre Multi-Services de l'AGECEM
  • Go 54.9%
  • HTML 23.5%
  • CSS 12%
  • JavaScript 9.2%
  • Makefile 0.3%
Find a file
2026-08-22 12:30:40 -04:00
.forgejo/workflows refactor: enlevement des refference au squellete dans le workflow 2026-05-25 12:07:07 -04:00
build refactor: build.version dans build package 2026-08-14 14:30:18 -04:00
cmd/cms Merge branch 'main' into ui-version 2026-08-14 14:37:35 -04:00
k8s feature: k8s setup 2026-08-14 17:24:43 -04:00
queries feature: signalement neutre affiche sur page membre 2026-08-21 16:06:48 -04:00
ui Merge branch 'main' into membres-signalements 2026-08-22 12:30:35 -04:00
.gitignore feature: k8s setup 2026-08-14 17:24:43 -04:00
auth.go refactor: application du nouveau error handling 2026-08-11 15:52:37 -04:00
client.go fix: utilisation des helpers dans client pour lexport de la caisse 2026-08-17 10:21:53 -04:00
client_test.go Merge branch 'main' into flag-startOverdueWorker 2026-08-14 12:03:13 -04:00
compose.yaml feature: requireRole() et clientFactory sur les routes 2026-08-04 17:33:41 -04:00
config.go feature: ajout des flags pour extra envs pour oauth 2026-08-04 17:09:20 -04:00
db.go feature: signalement neutre affiche sur page membre 2026-08-21 16:06:48 -04:00
Dockerfile refactor: build.version dans build package 2026-08-14 14:30:18 -04:00
entity.go feature: filtre par type pour list signalements 2026-08-21 14:40:58 -04:00
errors.go refactor: error hanlder pour limte dans la db 2026-08-21 11:40:53 -04:00
flag.go feature: ajout des flags pour extra envs pour oauth 2026-08-04 17:09:20 -04:00
go.mod feature: dependence pour verification du token dans api im sorry victor 2026-08-04 17:36:27 -04:00
go.sum feature: dependence pour verification du token dans api im sorry victor 2026-08-04 17:36:27 -04:00
handler.go feature: signalement neutre affiche sur page membre 2026-08-21 16:06:48 -04:00
handler_ui.go feature: signalement neutre affiche sur page membre 2026-08-21 16:06:48 -04:00
LICENSE Initial commit 2026-04-07 11:57:13 -04:00
login.go fix: refresh token est garde et on demande consantement pour refresh token a chaque connexion 2026-08-14 12:28:46 -04:00
Makefile feature: command cli avec cobra pour start reset et status du workerState 2026-08-14 11:59:53 -04:00
middleware.go feature: ui template pour les erreurs de auth 2026-08-07 15:31:54 -04:00
oauth.go feature: docs pour auth dans readme 2026-08-06 11:59:08 -04:00
opts.go feature: signalement neutre affiche sur page membre 2026-08-21 16:06:48 -04:00
README.md refactor: refactor des test de membre par user et changement de la relation des tables dans le readme ainsi que le seed 2026-08-11 11:26:23 -04:00
response.go Merge branch 'main' into unarchive-ui 2026-08-14 11:16:06 -04:00
response_ui.go feature: ui pour filtre de type de signalements 2026-08-21 14:42:57 -04:00
route.go Merge branch 'main' into consistent-confirmation 2026-08-22 12:25:13 -04:00
server.go feature: verification sur chaque tick de overdueWorker 2026-08-14 11:59:29 -04:00
session.go feature: separer middleware.go en plusieurs fichier et ajout de methodes pour oauth sur api 2026-08-04 17:08:06 -04:00
src.Dockerfile refactor: build.version dans build package 2026-08-14 14:30:18 -04:00
test.compose.yaml chores: bump postgres 17 -> 18 2026-06-15 13:38:57 -04:00
validate.go refactor: change package squelette pour package cms 2026-04-13 12:41:01 -04:00

cms

CMS!!!!!

Requirements

  • docker
  • docker compose
  • make

Deployment Local

  1. Copier et configurer le fichier d'environnement :
# .env

BOTTIN_SERVER_API_POSTGRES_DATABASE='bottin'
BOTTIN_SERVER_API_POSTGRES_PASSWORD='bottin'
BOTTIN_SERVER_API_POSTGRES_USER='bottin'
BOTTIN_SERVER_API_POSTGRES_HOST='bottin-db'
BOTTIN_SERVER_API_KEY='une_cle_secrete_si_necessaire'

CMS_BOTTIN_HOST='bottin-api'
CMS_BOTTIN_PORT='1312'
CMS_BOTTIN_KEY='une_cle_secrete_si_necessaire'

CMS_SERVER_API_POSTGRES_DATABASE='cms'
CMS_SERVER_API_POSTGRES_HOST='db'
CMS_SERVER_API_POSTGRES_PASSWORD='cms'
CMS_SERVER_API_POSTGRES_USER='cms'
CMS_SERVER_API_KEY='cms'

CMS_CLIENT_API_HOST='localhost'
CMS_CLIENT_API_PORT='8080'
CMS_CLIENT_API_KEY='cms'

CMS_SERVER_UI_USERNAME='cms'
CMS_SERVER_UI_PASSWORD='cms'

CMS_SERVER_UI_OAUTH_CLIENTID='<client-id>'
CMS_SERVER_UI_OAUTH_CLIENTSECRET='<client-secret>'
CMS_SERVER_UI_OAUTH_REDIRECTURL='http://localhost:2312/auth/callback/'
CMS_SERVER_UI_OAUTH_SESSIONKEY='<openssl rand -base64 32>'
CMS_SERVER_UI_OAUTH_SECURECOOKIES='false'
# CMS_SERVER_UI_OAUTH_ALLOWEDDOMAIN='agecem.com'   # optionnel
  1. Démarrer les conteneurs :
make deploy
  1. Insérer les données de seed (requiert les conteneurs en marche) :
make seed
  1. Accéder à l'interface :
Service URL
UI http://localhost:2312
API http://localhost:8080

Tests

# test.env

BOTTIN_SERVER_API_POSTGRES_DATABASE='bottin'
BOTTIN_SERVER_API_POSTGRES_PASSWORD='bottin'
BOTTIN_SERVER_API_POSTGRES_USER='bottin'
BOTTIN_SERVER_API_POSTGRES_HOST='bottin-db'
BOTTIN_SERVER_API_KEY='une_cle_secrete_si_necessaire'

CMS_SERVER_API_POSTGRES_DATABASE='cms'
CMS_SERVER_API_POSTGRES_HOST='db'
CMS_SERVER_API_POSTGRES_PASSWORD='cms'
CMS_SERVER_API_POSTGRES_USER='cms'

CMS_CLIENT_API_HOST='localhost'
CMS_CLIENT_API_PORT='8080'

CMS_BOTTIN_HOST='bottin-api'
CMS_BOTTIN_PORT='1312'
CMS_BOTTIN_KEY='une_cle_secrete_si_necessaire'

Schéma des bases de données

Deux bases distinctes : cms (ce service) et bottin (service externe, bottin/v12@v12.13.0).

Légende: <PK> clé primaire · <FK> clé étrangère réelle · ? colonne nullable · trait plein = FK dans la même base · trait pointillé = lien logique entre les deux bases.

classDiagram
    direction RL

    namespace CMS {
        class factures {
            &lt;PK&gt;UUID id
            FLOAT amount
            DATE date
            TIMESTAMP? archived_at
        }

        class achats {
            &lt;PK&gt;UUID id
            &lt;FK&gt;UUID facture_id
            &lt;FK&gt;UUID? caisse_id
            TEXT membre_id
            payment_method payment_method
            FLOAT? amount_received
            FLOAT? change_returned
            TIMESTAMP created_at
            TIMESTAMP? archived_at
        }

        class locations {
            &lt;PK&gt;UUID id
            &lt;FK&gt;UUID facture_id
            TEXT membre_id
            DATE return_date
            TIMESTAMP? returned_at
            TIMESTAMP? archived_at
        }

        class signalements {
            &lt;PK&gt;UUID id
            &lt;FK&gt;UUID? achat_id
            &lt;FK&gt;UUID? location_id
            TEXT? membre_id
            TEXT title
            TEXT message
            signal_type? signal_type
            TIMESTAMP date
            TIMESTAMP? archived_at
        }

        class stoques {
            &lt;PK&gt;UUID id
            TEXT name
            FLOAT prix_membre
            FLOAT prix_non_membre
            INTEGER quantity
            BOOLEAN can_buy
            BOOLEAN can_rent
            TIMESTAMP? archived_at
        }

        class categories {
            &lt;PK&gt;UUID id
            TEXT name UNIQUE
            TEXT categorie_type
            TIMESTAMP? archived_at
        }

        class stoques_categories {
            &lt;PK&gt;UUID id
            &lt;FK&gt;UUID stoque_id
            &lt;FK&gt;UUID categorie_id CASCADE
            UNIQUE stoque_id_categorie_id
        }

        class signalements_categories {
            &lt;PK&gt;UUID id
            &lt;FK&gt;UUID signalement_id
            &lt;FK&gt;UUID categorie_id CASCADE
            UNIQUE signalement_id_categorie_id
        }

        class stoques_achats {
            &lt;PK&gt;UUID id
            &lt;FK&gt;UUID achat_id
            &lt;FK&gt;UUID stoque_id
        }

        class stoques_locations {
            &lt;PK&gt;UUID id
            &lt;FK&gt;UUID location_id
            &lt;FK&gt;UUID stoque_id
            TIMESTAMP? returned_at
        }

        class caisse {
            &lt;PK&gt;UUID id
            DATE date
            FLOAT opening_fund
            INTEGER pieces_5c, pieces_10c, pieces_25c
            INTEGER pieces_1, pieces_2
            INTEGER bills_5, bills_10, bills_20
            INTEGER rolls_5c, rolls_10c, rolls_25c
            INTEGER rolls_1, rolls_2
            FLOAT? total_received
            FLOAT? total_returned
            TIMESTAMP created_at
            TIMESTAMP opened_at
            TIMESTAMP? closed_at
            TIMESTAMP? archived_at
        }

        class historique_caisse {
            &lt;PK&gt;UUID id
            &lt;FK&gt;UUID caisse_id
            FLOAT expected_amount
            FLOAT counted_amount
            FLOAT discrepancy
            INTEGER pieces_5c, pieces_10c, pieces_25c
            INTEGER pieces_1, pieces_2
            INTEGER bills_5, bills_10, bills_20
            INTEGER rolls_5c, rolls_10c, rolls_25c
            INTEGER rolls_1, rolls_2
            TEXT historique_type
            TIMESTAMP created_at
            TIMESTAMP? archived_at
        }

        class caisse_mouvement {
            &lt;PK&gt;UUID id
            &lt;FK&gt;UUID caisse_id
            &lt;FK&gt;UUID user_id
            FLOAT amount
            TEXT historique_type
            TEXT? note
            TIMESTAMP created_at
        }

        class membre_cache {
            &lt;PK&gt;TEXT membre_id
            TEXT first_name
            TEXT last_name
            TEXT programme
        }

        class users {
            &lt;PK&gt;UUID id
            TEXT email UNIQUE
            TEXT? google_sub UNIQUE
            TEXT? name
            user_role role
            TIMESTAMP created_at
            TIMESTAMP? last_login_at
            TIMESTAMP? archived_at
        }

        class payment_method {
            <<enum>>
            debit
            credit
            comptant
        }

        class signal_type {
            <<enum>>
            warning
            critical
            neutral
        }

        class user_role {
            <<enum>>
            commis
            permanence
            commis_senior
            admin
        }
    }

    namespace Bottin {
        class programmes {
            &lt;PK&gt;TEXT id
            TEXT name
            TIMESTAMP? archived_at
        }

        class membres {
            &lt;PK&gt;VARCHAR7 id
            TEXT first_name
            TEXT last_name
            TEXT prefered_name
            &lt;FK&gt;TEXT programme_id
            TEXT_ARRAY? phones
            TIMESTAMP? archived_at
        }
    }

    factures "1..1" -- "0..n" achats : facture_id
    factures "1..1" -- "0..n" locations : facture_id
    caisse "0..1" -- "0..n" achats : caisse_id
    caisse "1..1" -- "0..n" historique_caisse : caisse_id
    caisse "1..1" -- "0..n" caisse_mouvement : caisse_id
    users "1..1" -- "0..n" caisse_mouvement : user_id
    achats "0..1" -- "0..n" signalements : achat_id
    locations "0..1" -- "0..n" signalements : location_id
    achats "1..1" -- "0..n" stoques_achats : achat_id
    stoques "1..1" -- "0..n" stoques_achats : stoque_id
    locations "1..1" -- "0..n" stoques_locations : location_id
    stoques "1..1" -- "0..n" stoques_locations : stoque_id
    stoques "1..1" -- "0..n" stoques_categories : stoque_id
    categories "1..1" -- "0..n" stoques_categories : categorie_id
    signalements "1..1" -- "0..n" signalements_categories : signalement_id
    categories "1..1" -- "0..n" signalements_categories : categorie_id

    achats ..> payment_method
    signalements ..> signal_type
    users ..> user_role

    programmes "1..1" -- "0..n" membres : programme_id

    membres "1..1" .. "0..n" achats : membre_id
    membres "1..1" .. "0..n" locations : membre_id
    membres "0..1" .. "0..n" signalements : membre_id
    membres "1..1" .. "0..1" membre_cache : copie locale

    classDef cms fill:#E8F0FE,stroke:#4A5568,color:#1A202C
    classDef bottin fill:#FFF4E5,stroke:#8B5A2B,color:#1A202C
    classDef enums fill:#EDE9FE,stroke:#6D28D9,color:#1A202C

    cssClass "factures,achats,locations,signalements,stoques,categories,stoques_categories,signalements_categories,stoques_achats,stoques_locations,caisse,historique_caisse,caisse_mouvement,membre_cache,users" cms
    cssClass "programmes,membres" bottin
    cssClass "payment_method,signal_type,user_role" enums

Notes

  • membre_id n'est jamais une FK. achats, locations, signalements et membre_cache portent un membre_id texte qui correspond à bottin.membres.id, mais les deux bases sont distinctes : aucune intégrité référentielle n'est appliquée par Postgres.
  • membre_cache est une copie de secours, peuplée lors de CreateAchats / CreateLocations. Elle sert de repli quand Bottin est injoignable ou que le membre y a été supprimé.
  • factures -> achats / locations : le schéma autorise plusieurs achats ou locations par facture, mais l'application en crée toujours exactement un.
  • categories est partagée entre stoques et signalements, départagée par categorie_type ('stoque' ou 'signalement'). Les deux tables de liaison suppriment en cascade quand une catégorie disparaît, mais pas quand le stoque ou le signalement disparaît.
  • caisse / historique_caisse répètent les 14 mêmes colonnes de dénominations (pieces_*, bills_*, rolls_*), regroupées ici sur cinq lignes pour la lisibilité.
  • Les contraintes CHECK (length(name) > 0, valeurs de categorie_type et de historique_type) et le trigger de validation des téléphones de Bottin ne sont pas représentés sur le diagramme.
  • Deux types ont été renommés pour Mermaid, qui interprète les parenthèses comme une méthode et les crochets comme un noeud : membres.id est réellement VARCHAR(7) (écrit VARCHAR7) et membres.phones est réellement TEXT[] (écrit TEXT_ARRAY).

Authentification

OAuth avec Google pour l'authentification des utilisateurs. Après la connexion, le serveur UI crée une session sécurisée à l'aide d'un cookie chiffré avec le token retourné par Google. Seuls ceux qui existent comme entrées dans la table users sont authentifiés. Le serveur UI sort le token du cookie et l'envoie à l'API dans le header Authorization. L'API valide le token pour vérifier que c'est un token de Google, une fois vérifié la requête vers l'API va être exécutée comme normal. Le token est revalidé à chaque requête, seules les clés publiques de Google sont gardées en cache.

Sur une base neuve, CMS_SERVER_API_OAUTH_BOOTSTRAPEMAILS inscrit les premiers admins au démarrage, sinon personne ne peut se connecter.

Accès direct à l'API est possible avec le CLI avec les fichiers cmd/cms/login.go et login.go. Ces deux fichiers ne sont jamais utilisés pour le workflow normal avec le serveur UI.

cmd/cms/login.go

Ce fichier nous laisse faire des commandes cobra pour login avec le CLI pour accès direct sur l'API sans le serveur UI avec les commandes login, token et logout.

login.go

Enable un accès API direct avec curl.

Login() lance un petit serveur HTTP local sur 127.0.0.1:8085 pour recevoir le code de Google, parce que Google redirige le navigateur vers localhost. Le client OAuth Google doit donc avoir deux redirect URI enregistrées, celle du serveur UI et http://localhost:8085/callback.

Pour envoyer des requêtes HTTP directement à l'API avec curl on peut soit ouvrir une session avec l'UI et login comme d'habitude ou suivre les étapes suivantes pour le faire sans le serveur UI:

  1. Build le binaire et les variables d'environnement
go build -o ./cms ./cmd/cms

export CMS_CLIENT_API_HOST=localhost
export CMS_CLIENT_OAUTH_CLIENTID='842665291974-h40n2c0qeqte317uv2cfujhgu7s12cqu.apps.googleusercontent.com'
export CMS_CLIENT_OAUTH_CLIENTSECRET='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'
  1. Exécuter votre requête avec curl
curl -H "Authorization: Bearer $(./cms token)" http://localhost:8080/v1/me/
  1. Un onglet sur votre navigateur va ouvrir pour login avec Google et une fois authentifié la requête HTTP va s'exécuter. Les fois suivantes le token gardé dans ~/.config/cms/token.json est réutilisé sans ouvrir le navigateur.

Ce fichier est l'équivalent de session.go pour le CLI, cmd/cms/login.go utilise des fonctions déclarées ici.

session.go

Gère les cookies sur le serveur UI qui contiennent les infos de la session comme le token, le refresh token, le rôle, l'expiration, etc. Le cookie est chiffré et pas juste signé parce qu'il contient le refresh token.

Gère différentes fonctions pour les cookies comme clear et set cookies.

Contient pendingAuth, un short lived state pour un login en attente/in progress.

expiringSoon() dit si le token expire bientôt, la constante sessionRefreshWindow est de 2 minutes.

APIClientFactory

Avant, avec le bearer token partagé, il y avait un APIClient initialisé dans server.go qui existait pour toujours avec le même token. Maintenant que chaque utilisateur a un token différent, un même APIClient ne va plus fonctionner. Il faut donc créer un nouveau APIClient avec chaque requête. voki.Caller fixe son token à la construction, il n'y a pas moyen de le changer par appel.

auth.go

Gestion de l'authentification et autorisation générale.

Verify le token sur le serveur API (signature, audience, expiration, etc.)

Verify si l'utilisateur existe dans la table users.

denyJSON() retourne des erreurs lorsque l'auth est refusé.

Fonctionnalité RBAC sur les routes de l'API avec APIAuth.RequireRole().

Claims c'est le contenu décodé du token (courriel, nom, sub, expiration). C'est ce que le TokenVerifier retourne après avoir vérifié le token.

oauth.go

Config pour OAuth par Google.

Méthodes pour créer un token, créer une session avec un token.

Expiration du token et le refresh token sont set par Google (OAuth provider).

refreshSession() est ici, il refait un token avec le refresh token sans rien demander à l'utilisateur.

middleware.go

Appel expiringSoon() chaque requête pour vérifier si le token expire dans moins de 2 minutes, si oui on refresh la session.

Si une route utilise RequireRole(), la méthode vérifie si l'utilisateur contient le rôle minimum pour l'accès. Il vérifie le rôle sur la session avec la sessionContext key. C'est juste pour l'affichage, l'API a son propre RequireRole() qui revérifie le rôle en base.