Produit minimal viable #1

Merged
vlbeaudoin merged 8 commits from victor/wip into main 2024-12-30 19:48:53 -05:00
19 changed files with 635 additions and 85 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

51
cmd.go Normal file
View file

@ -0,0 +1,51 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/spf13/cobra"
)
func init() {
// rootCmd
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file (default is $HOME/.bottin.agendas.yaml)")
if err := BindFlags(rootCmd.PersistentFlags()); err != nil {
log.Fatal(err)
}
}
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "agendas",
//Args: cobra.(0),
Short: "Gestion de distribution d'agendas",
Run: func(c *cobra.Command, args []string) {
log.Println("Starting bottin agendas microservice")
ctx := context.TODO()
cfg, err := ParseConfig()
if err != nil {
log.Fatal(err)
}
if len(args) > 0 {
if args[0] == "debugConfig" {
log.Println("debug: Printing current config")
cfgAsBytes, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
log.Fatal("Cannot marshal config for printing:", err)
}
fmt.Println(string(cfgAsBytes))
}
}
if err := run(ctx, cfg); err != nil {
log.Fatal(err)
}
},
}

16
compose.yaml Normal file
View file

@ -0,0 +1,16 @@
name: 'agendas-db'
services:
db:
image: 'postgres:16'
environment:
POSTGRES_USER: "${DB_USER:?}"
POSTGRES_PASSWORD: "${DB_PASSWORD:?}"
POSTGRES_DATABASE: "${DB_DATABASE:?}"
restart: 'unless-stopped'
ports:
- '5432:5432'
volumes:
- 'db-data:/var/lib/postgresql/data/'
- '/etc/localtime:/etc/localtime:ro'
volumes:
db-data:

View file

@ -1,7 +1,90 @@
package main package main
import "git.agecem.com/bottin/bottin/v10/pkg/bottin" import (
"fmt"
"os"
"strings"
"git.agecem.com/bottin/bottin/v10/pkg/bottin"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Config struct { type Config struct {
// Bottin holds a client config to contact a bottin API server
// Cannot be set using environment variables
Bottin bottin.APIClientConfig Bottin bottin.APIClientConfig
DB struct {
Database string
Host string
Password string
Port int
SSLMode string
Username string
}
// TLS holds options for TLS / SSL / HTTPS
TLS struct {
// Cert holds the public certificate (or path to a file containing one) for user interface TLS
Cert string
// Key holds the private key (or path to a file containing one) for user interface TLS
Key string
// Enabled holds the TLS activation state
Enabled bool
}
// Credentials holds username-password pairs for basic authentication
Credentials map[string]string
// Port holds the port on which to expose the user interface
Port int
}
func ParseConfig() (cfg Config, err error) {
return cfg, viper.Unmarshal(&cfg)
}
var cfgFile string
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find home directory.
home, err := os.UserHomeDir()
cobra.CheckErr(err)
// Search config in home directory with name ".bottin.agendas" (without extension).
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".bottin.agendas")
}
// Read in env, will find matching viper bindings in flag.go [BindFlags]
viper.SetEnvPrefix("AGENDAS")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
func init() {
cobra.OnInitialize(initConfig)
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
} }

114
db.go Normal file
View file

@ -0,0 +1,114 @@
package main
import (
"context"
"fmt"
"log"
"git.agecem.com/bottin/agendas/queries"
"github.com/jackc/pgx/v5/pgxpool"
)
type DBClient struct {
Pool *pgxpool.Pool
}
func (d DBClient) Ping(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
if d.Pool == nil {
return fmt.Errorf("db: cannot check health using nil connection pool")
}
return d.Pool.Ping(ctx)
}
}
func (d DBClient) Init(ctx context.Context) error {
//TODO check context is not closed
//TODO check *DB is not nil
log.Println("warning: DBClient [Init] not properly checked")
// Init
if _, err := d.Pool.Exec(ctx, queries.SQLSchema()); err != nil {
return err
}
return nil
}
func (d DBClient) CreateTransaction(ctx context.Context, transaction Transaction) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
if d.Pool == nil {
return fmt.Errorf("db: cannot check health using nil connection pool")
}
// CreateTransaction
commandTag, err := d.Pool.Exec(ctx, `
INSERT INTO "transactions" (
membre_id,
is_perpetual
)
VALUES (
$1,
$2
);
`, transaction.MembreID, transaction.IsPerpetual)
if err != nil {
if err.Error() == `ERROR: duplicate key value violates unique constraint "transactions_pkey" (SQLSTATE 23505)` {
return fmt.Errorf("Membre '%s' a déjà reçu son agenda %s.",
transaction.MembreID,
func() string {
if transaction.IsPerpetual {
return "perpétuel"
} else {
return "non-perpétuel"
}
}(),
)
}
return err
}
if commandTag.RowsAffected() == 0 {
return fmt.Errorf("No rows inserted")
}
return nil
}
}
func (d DBClient) ReadTransaction(ctx context.Context, membreID string, isPerpetual bool) (Transaction, error) {
select {
case <-ctx.Done():
return Transaction{}, ctx.Err()
default:
if d.Pool == nil {
return Transaction{}, fmt.Errorf("db: cannot check health using nil connection pool")
}
//TODO ReadTransaction
return Transaction{}, fmt.Errorf("db: ReadTransaction not implemented")
}
}
func (d DBClient) CountTransactions(ctx context.Context) (membresSansAgenda, membresAvecPerpetuel, membresAvecNonPerpetuel, membresAvecTout int, err error) {
select {
case <-ctx.Done():
err = ctx.Err()
return
default:
if d.Pool == nil {
err = fmt.Errorf("db: cannot check health using nil connection pool")
return
}
//TODO CountTransactions
err = fmt.Errorf("db: CountTransactions not implemented")
return
}
}

9
entity.go Normal file
View file

@ -0,0 +1,9 @@
package main
import "time"
type Transaction struct {
MembreID string `db:"membre_id"`
GivenAt *time.Time `db:"given_at"`
IsPerpetual bool `db:"is_perpetual"`
}

51
flag.go Normal file
View file

@ -0,0 +1,51 @@
package main
import (
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
const (
configPort = "Port"
defaultPort = 3333
flagPort = "port"
configTLSEnabled = "TLS.Enabled"
defaultTLSEnabled = true
flagTLSEnabled = "tls-enabled"
configTLSCert = "TLS.Cert"
defaultTLSCert = ""
flagTLSCert = "tls-cert"
configTLSKey = "TLS.Key"
defaultTLSKey = ""
flagTLSKey = "tls-key"
)
// BindClientFlags declares client-related flags and config options in the specified *pflag.FlagSet
func BindFlags(set *pflag.FlagSet) error {
// Credentials -> seulement par config
set.Int(flagPort, defaultPort, "User interface port")
if err := viper.BindPFlag(configPort, set.Lookup(flagPort)); err != nil {
return err
}
set.Bool(flagTLSEnabled, defaultTLSEnabled, "User interface TLS state")
if err := viper.BindPFlag(configTLSEnabled, set.Lookup(flagTLSEnabled)); err != nil {
return err
}
set.String(flagTLSKey, defaultTLSKey, "User interface TLS private key (or path to file)")
if err := viper.BindPFlag(configTLSKey, set.Lookup(flagTLSKey)); err != nil {
return err
}
set.String(flagTLSCert, defaultTLSCert, "User interface TLS certificate (or path to file)")
if err := viper.BindPFlag(configTLSCert, set.Lookup(flagTLSCert)); err != nil {
return err
}
return nil
}

9
go.mod
View file

@ -5,17 +5,20 @@ go 1.23.4
require ( require (
codeberg.org/vlbeaudoin/voki/v3 v3.0.1 codeberg.org/vlbeaudoin/voki/v3 v3.0.1
git.agecem.com/bottin/bottin/v10 v10.4.1 git.agecem.com/bottin/bottin/v10 v10.4.1
github.com/jackc/pgx/v5 v5.7.1
github.com/labstack/echo/v4 v4.13.3 github.com/labstack/echo/v4 v4.13.3
golang.org/x/term v0.27.0 github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0
) )
require ( require (
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 // indirect github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
@ -28,8 +31,6 @@ require (
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.19.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect

8
go.sum
View file

@ -2,6 +2,7 @@ codeberg.org/vlbeaudoin/voki/v3 v3.0.1 h1:pFjd/ZKsu4eOzRJYViE9F1S3RglSkAuIiqCo9I
codeberg.org/vlbeaudoin/voki/v3 v3.0.1/go.mod h1:+6LMXosAu2ijNKV04sMwkeujpH+cghZU1fydqj2y95g= codeberg.org/vlbeaudoin/voki/v3 v3.0.1/go.mod h1:+6LMXosAu2ijNKV04sMwkeujpH+cghZU1fydqj2y95g=
git.agecem.com/bottin/bottin/v10 v10.4.1 h1:mRZfqnLhGN9Qb+iQvfOvZM5pxXqSlkiXqIMX59zPAS8= git.agecem.com/bottin/bottin/v10 v10.4.1 h1:mRZfqnLhGN9Qb+iQvfOvZM5pxXqSlkiXqIMX59zPAS8=
git.agecem.com/bottin/bottin/v10 v10.4.1/go.mod h1:KTwlqY5XdVi9F7cpwy3hxYN1DQm+74tBv7Wc9rfKXuM= git.agecem.com/bottin/bottin/v10 v10.4.1/go.mod h1:KTwlqY5XdVi9F7cpwy3hxYN1DQm+74tBv7Wc9rfKXuM=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@ -16,6 +17,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -48,6 +51,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@ -58,6 +62,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
@ -96,8 +102,6 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=

View file

@ -11,14 +11,16 @@ import (
) )
// Handling // Handling
func UIIndex(ctx context.Context, bottinClient *bottin.APIClient) echo.HandlerFunc { func UIIndex(ctx context.Context, bottinClient *bottin.APIClient, dbClient *DBClient) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
type data struct { type data struct {
Error string Error string
BottinHealthResponse bottin.ReadHealthResponse BottinHealthResponse bottin.ReadHealthResponse
IsDBUp bool
Result string
} }
return c.Render(http.StatusOK, "index", func() (d data) { return c.Render(http.StatusOK, "app", func() (d data) {
if err := func() error { if err := func() error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -36,6 +38,16 @@ func UIIndex(ctx context.Context, bottinClient *bottin.APIClient) echo.HandlerFu
} }
d.BottinHealthResponse = bottinHealthResponse d.BottinHealthResponse = bottinHealthResponse
// Check db health
if dbClient == nil {
return fmt.Errorf("Impossible de contacter la base de données, le client DB est nil")
}
if err := dbClient.Ping(ctx); err != nil {
return err
}
d.IsDBUp = true
// No errors // No errors
return nil return nil
} }
@ -51,40 +63,81 @@ func UIIndex(ctx context.Context, bottinClient *bottin.APIClient) echo.HandlerFu
} }
} }
func UIReadMembre(ctx context.Context, bottinClient *bottin.APIClient) echo.HandlerFunc { /*
UICreateTransaction gère la création des transactions
TODO:
- Fully implement
- Check context is not closed
*/
func UICreateTransaction(ctx context.Context, cfg Config, bottinClient *bottin.APIClient, dbClient *DBClient) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
type data struct { type data struct {
Error string Error string
BottinMembreResponse bottin.ReadMembreResponse Result string
//BottinHealth bottin.ReadHealthResponse
} }
return c.Render(http.StatusOK, "index", func() (d data) { return c.Render(http.StatusOK, "app", func() (d data) {
if err := func() error { if err := func() error {
select { if bottinClient == nil {
case <-ctx.Done(): return fmt.Errorf("Cannot operate on nil *bottin.APIClient")
return fmt.Errorf("Impossible de contacter le serveur: %s", ctx.Err()) }
default:
// Check client
if bottinClient == nil {
return fmt.Errorf("Impossible de contacter le serveur, le client API est nil")
}
var err error /*
bottinReadHealthResponse, err := bottinClient.ReadHealth(ctx)
// Check membre
d.BottinMembreResponse, err = bottinClient.ReadMembre(ctx, c.QueryParam("m"))
if err != nil { if err != nil {
return err return err
} }
// No errors d.BottinHealth = bottinReadHealthResponse
return nil */
}
}(); err != nil {
// Send error to user
d.Error = err.Error()
// Log error isPerpetual := c.FormValue("is_perpetual") == "on"
membreID := c.FormValue("membre_id")
if membreID == "" {
return fmt.Errorf("Aucun numéro étudiant sélectionné. Assurez-vous de cliquer sur la case 'Numéro étudiant:' avant de scanner.")
}
//TODO check if membre already received before checking bottin
// check if membre exists in bottin
membreResponse, err := bottinClient.ReadMembre(ctx, membreID)
if err != nil {
if err.Error() == "400 no rows in result set" {
return fmt.Errorf("Numéro étudiant introuvable %s", membreID)
}
return err
}
if membreResponse.Data.Membre.ID != membreID {
return fmt.Errorf("Bottin a retourné '%s' en demandant '%s'", membreID, membreResponse.Data.Membre.ID)
}
// dbclient.CreateTransaction
if err := dbClient.CreateTransaction(ctx, Transaction{
MembreID: membreID,
IsPerpetual: isPerpetual,
}); err != nil {
return err
}
// Prepare result message
d.Result = fmt.Sprintf("Membre %s peut recevoir son agenda %s",
membreID,
func() string {
if isPerpetual {
return "perpétuel"
} else {
return "non-perpétuel"
}
}(),
)
return nil
}(); err != nil {
d.Error = err.Error()
log.Println("err:", d.Error) log.Println("err:", d.Error)
} }
return return

61
main.go
View file

@ -3,37 +3,17 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"net/http" "net/http"
"codeberg.org/vlbeaudoin/voki/v3" "codeberg.org/vlbeaudoin/voki/v3"
"git.agecem.com/bottin/bottin/v10/pkg/bottin" "git.agecem.com/bottin/bottin/v10/pkg/bottin"
"golang.org/x/term" "github.com/jackc/pgx/v5/pgxpool"
) )
// Entry // Entry
func init() {}
func main() { func main() {
cfg := Config{} // Start commandline parsing then call `run`
cfg.Bottin.Host = "api.bottin.agecem.com" Execute()
cfg.Bottin.Port = 443
cfg.Bottin.TLS.Enabled = true
ctx := context.TODO()
fmt.Println("bottin password (no echo): ")
password, err := term.ReadPassword(0)
if err != nil {
log.Fatal(err)
}
cfg.Bottin.Key = string(password)
if err := run(ctx, cfg); err != nil {
log.Fatal(err)
}
} }
func run(ctx context.Context, cfg Config) error { func run(ctx context.Context, cfg Config) error {
@ -41,23 +21,38 @@ func run(ctx context.Context, cfg Config) error {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
default: default:
var protocol string
if cfg.Bottin.TLS.Enabled {
protocol = "https"
} else {
protocol = "http"
}
bottinClient := bottin.APIClient{Caller: voki.New( bottinClient := bottin.APIClient{Caller: voki.New(
http.DefaultClient, http.DefaultClient,
cfg.Bottin.Host, cfg.Bottin.Host,
cfg.Bottin.Key, cfg.Bottin.Key,
cfg.Bottin.Port, cfg.Bottin.Port,
protocol, func() string {
if cfg.Bottin.TLS.Enabled {
return "https"
} else {
return "http"
}
}(),
)} )}
if err := RunServer(ctx, &bottinClient); err != nil && err != http.ErrServerClosed { // connect to db
dbPool, err := pgxpool.New(ctx,
fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
cfg.DB.Username,
cfg.DB.Password,
cfg.DB.Host,
cfg.DB.Port,
cfg.DB.Database,
cfg.DB.SSLMode,
))
if err != nil {
return err
}
defer dbPool.Close()
dbClient := DBClient{Pool: dbPool}
if err := RunServer(ctx, cfg, &bottinClient, &dbClient); err != nil && err != http.ErrServerClosed {
return err return err
} }

10
queries/queries.go Normal file
View file

@ -0,0 +1,10 @@
package queries
import _ "embed"
//go:embed schema.sql
var sqlSchema string
func SQLSchema() string {
return sqlSchema
}

7
queries/schema.sql Normal file
View file

@ -0,0 +1,7 @@
-- Schema
CREATE TABLE IF NOT EXISTS transactions (
given_at TIMESTAMP DEFAULT current_timestamp,
membre_id VARCHAR(7) NOT NULL CHECK (length(membre_id) > 0),
is_perpetual BOOLEAN NOT NULL,
PRIMARY KEY (membre_id, is_perpetual)
);

View file

@ -2,14 +2,17 @@ package main
import ( import (
"context" "context"
"crypto/subtle"
"fmt" "fmt"
"log"
"git.agecem.com/bottin/agendas/ui"
"git.agecem.com/bottin/bottin/v10/pkg/bottin" "git.agecem.com/bottin/bottin/v10/pkg/bottin"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
) )
func RunServer(ctx context.Context, bottinClient *bottin.APIClient) error { func RunServer(ctx context.Context, cfg Config, bottinClient *bottin.APIClient, dbClient *DBClient) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
@ -18,16 +21,57 @@ func RunServer(ctx context.Context, bottinClient *bottin.APIClient) error {
return fmt.Errorf("nil bottin client") return fmt.Errorf("nil bottin client")
} }
if dbClient == nil {
return fmt.Errorf("nil dbClient")
}
if err := dbClient.Init(ctx); err != nil {
return err
}
e := echo.New() e := echo.New()
e.Renderer = NewRenderer() r := ui.NewRenderer()
if r == nil {
return fmt.Errorf("nil renderer")
}
e.Renderer = r
e.Pre(middleware.AddTrailingSlash()) e.Pre(middleware.AddTrailingSlash())
e.GET("/", UIIndex(ctx, bottinClient)) // basic auth
if len(cfg.Credentials) == 0 {
return fmt.Errorf("UI requires at least one credential (config key `Credentials` of type map[string]string)")
}
e.GET("/membre/", UIReadMembre(ctx, bottinClient)) e.Pre(middleware.BasicAuth(
func(username, password string, c echo.Context) (bool, error) {
for validUser, validPass := range cfg.Credentials {
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(validUser)) == 1
passOK := subtle.ConstantTimeCompare([]byte(password), []byte(validPass)) == 1
if userOK && passOK {
// log successful basic auths username
log.Println("login ok for user", username)
return e.Start(":3333") return true, nil
}
}
return false, nil
}),
)
e.GET("/", UIIndex(ctx, bottinClient, dbClient))
//e.GET("/transaction/", UIReadTransaction
e.POST("/transaction/", UICreateTransaction(ctx, cfg, bottinClient, dbClient))
address := fmt.Sprintf(":%d", cfg.Port)
if cfg.TLS.Enabled {
return e.StartTLS(address, cfg.TLS.Cert, cfg.TLS.Key)
} else {
return e.Start(address)
}
} }
} }

121
ui/app.html Normal file
View file

@ -0,0 +1,121 @@
{{ define "app" }}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>
AGECEM | Agenda
</title>
<style>
body {
/* Center the form on the page */
margin: 0 auto;
width: 400px;
/* Form outline */
padding: 1em;
border: 1px solid #ccc;
border-radius: 1em;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
form li + li {
margin-top: 1em;
}
label {
/* Uniform size & alignment */
display: inline-block;
width: 90px;
text-align: right;
}
input,
textarea {
/* To make sure that all text fields have the same font settings
By default, textareas have a monospace font */
font: 1em sans-serif;
/* Uniform text field size */
width: 300px;
box-sizing: border-box;
/* Match form field borders */
border: 1px solid #999;
}
input:focus,
textarea:focus {
/* Additional highlight for focused elements */
border-color: #000;
}
textarea {
/* Align multiline text fields with their labels */
vertical-align: top;
/* Provide space to type some text */
height: 5em;
}
.button {
/* Align buttons with the text fields */
padding-left: 90px; /* same size as the label elements */
}
button {
/* This extra margin represent roughly the same space as the space
between the labels and their text fields */
margin-left: 0.5em;
}
</style>
</head>
<body>
<h2>
Distribution d'agendas aux membres de l'AGECEM
</h2>
<p>
1) Si l'agenda demandé est perpétuel, cochez 'Perpétuel?:'<br>
<br>
2) Sélectionnez le champs 'Numéro étudiant:'<br>
<br>
3) Scannez la carte étudiante d'unE membre<br>
-ou-<br>
Entrez manuellement le code à 7 chiffres<br>
<br>
4) Si aucune erreur ne survient, la personne est libre de partir avec son agenda
</p>
<hr>
<form action="/transaction/" method="post">
<ul>
<li>
<label for="is_perpetual">Perpétuel?:</label>
<input type="checkbox" id="is_perpetual" name="is_perpetual"/>
</li>
<li>
<label for="membre_id">Numéro étudiant:</label>
<input type="text" id="membre_id" name="membre_id" autofocus/>
</li>
<li class="button">
<button type="submit">Scan</button>
</li>
</ul>
</form>
{{ if .Error }}<p class="result">👎 {{ .Error }}</p>{{ end }}
{{ if .Result }}<p class="result">👍 {{ .Result }}</p>{{ end }}
</body>
</html>
{{ end }}

View file

@ -1,10 +1,8 @@
package ui package ui
import "embed" import (
"embed"
)
//go:embed *.html //go:embed *.html
var htmlFS embed.FS var htmlFS embed.FS
func HTMLFS() embed.FS {
return htmlFS
}

View file

@ -1,4 +0,0 @@
{{ define "index" }}
hello world
{{ . }}
{{ end }}

View file

@ -1,10 +1,9 @@
package main package ui
import ( import (
"io" "io"
"text/template" "text/template"
"git.agecem.com/bottin/agendas/ui"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@ -18,6 +17,6 @@ func (t *Renderer) Render(w io.Writer, name string, data any, c echo.Context) er
func NewRenderer() *Renderer { func NewRenderer() *Renderer {
return &Renderer{ return &Renderer{
templates: template.Must(template.ParseFS(ui.HTMLFS(), "*html")), templates: template.Must(template.ParseFS(htmlFS, "*html")),
} }
} }

View file

@ -1,3 +0,0 @@
{{ define "test" }}
{{ . }}
{{ end }}