diff --git a/cmd/coursesense/main.go b/cmd/coursesense/main.go index 5627866..7505b33 100644 --- a/cmd/coursesense/main.go +++ b/cmd/coursesense/main.go @@ -9,9 +9,9 @@ import ( "time" "github.com/jacobmichels/Course-Sense-Go/config" - "github.com/jacobmichels/Course-Sense-Go/firestore" "github.com/jacobmichels/Course-Sense-Go/notifier" "github.com/jacobmichels/Course-Sense-Go/register" + "github.com/jacobmichels/Course-Sense-Go/repository" "github.com/jacobmichels/Course-Sense-Go/server" "github.com/jacobmichels/Course-Sense-Go/trigger" "github.com/jacobmichels/Course-Sense-Go/webadvisor" @@ -29,7 +29,7 @@ func main() { cancel() }() - cfg, err := config.ReadConfig() + cfg, err := config.ParseConfig() if err != nil { log.Panicf("failed to get config: %s", err) } @@ -39,15 +39,15 @@ func main() { log.Panicf("failed to create WebAdvisorSectionService: %s", err) } - firestoreService, err := firestore.NewFirestoreWatcherService(ctx, cfg.Firestore.ProjectID, cfg.Firestore.SectionCollectionID, cfg.Firestore.WatcherCollectionID, cfg.Firestore.CredentialsFilePath) + repository, err := repository.New(ctx, cfg.Database) if err != nil { - log.Panicf("failed to create FirestoreWatcherService: %s", err) + log.Panicf("failed to create repository: %s", err) } - emailNotifier := notifier.NewEmail(cfg.Smtp.Host, cfg.Smtp.Username, cfg.Smtp.Password, cfg.Smtp.From, cfg.Smtp.Port) + emailNotifier := notifier.NewEmail(cfg.Notifications.EmailSmtp.Host, cfg.Notifications.EmailSmtp.Username, cfg.Notifications.EmailSmtp.Password, cfg.Notifications.EmailSmtp.From, cfg.Notifications.EmailSmtp.Port) - register := register.NewRegister(webadvisorService, firestoreService) - trigger := trigger.NewTrigger(webadvisorService, firestoreService, emailNotifier) + register := register.NewRegister(webadvisorService, repository) + trigger := trigger.NewTrigger(webadvisorService, repository, emailNotifier) go func() { log.Println("starting trigger ticker") diff --git a/config/config.go b/config/config.go index 736e03c..68fdf37 100644 --- a/config/config.go +++ b/config/config.go @@ -8,38 +8,30 @@ import ( "github.com/spf13/viper" ) -type Config struct { - Firestore struct { - ProjectID string `mapstructure:"project_id"` - CredentialsFilePath string `mapstructure:"credentials_file"` - SectionCollectionID string `mapstructure:"section_collection_id"` - WatcherCollectionID string `mapstructure:"watcher_collection_id"` - } - Smtp struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - From string `mapstructure:"from"` - } +// returns a slice of the supported db names +// a function is used to access this list instead of a global slice to prevent accidental mutation of the slice +func getSupportedDbs() []string { + return []string{"sqlite", "firestore"} } -func ReadConfig() (Config, error) { +// reads the config file +// returns an error if the file couldn't be read or is invalid +func ParseConfig() (Config, error) { viper.AddConfigPath(".") viper.SetConfigName("config") viper.SetConfigType("yaml") - viper.SetDefault("firestore.project_id", "") - viper.SetDefault("firestore.credentials_file", "") - viper.SetDefault("firestore.section_collection_id", "sections") - viper.SetDefault("firestore.watcher_collection_id", "watchers") - viper.SetDefault("smtp.port", 0) - viper.SetDefault("smtp.host", "") - viper.SetDefault("smtp.username", "") - viper.SetDefault("smtp.password", "") - viper.SetDefault("smtp.from", "") - viper.SetDefault("auth.username", "") - viper.SetDefault("auth.password", "") + viper.SetDefault("database.type", "") + viper.SetDefault("database.firestore.project_id", "") + viper.SetDefault("database.firestore.credentials_file", "") + viper.SetDefault("database.firestore.section_collection_id", "sections") + viper.SetDefault("database.firestore.watcher_collection_id", "watchers") + viper.SetDefault("database.sqlite.connection_string", "") + viper.SetDefault("notifications.emailsmtp.port", 0) + viper.SetDefault("notifications.emailsmtp.host", "") + viper.SetDefault("notifications.emailsmtp.username", "") + viper.SetDefault("notifications.emailsmtp.password", "") + viper.SetDefault("notifications.emailsmtp.from", "") viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.AutomaticEnv() @@ -53,10 +45,28 @@ func ReadConfig() (Config, error) { } } - var config Config - if err := viper.Unmarshal(&config); err != nil { + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { return Config{}, fmt.Errorf("failed to unmarshal config: %s", err) } - return config, nil + if err := validateConfig(cfg); err != nil { + return Config{}, fmt.Errorf("invalid config: %w", err) + } + + return cfg, nil +} + +func validateConfig(cfg Config) error { + if cfg.Database.Type == "" { + return fmt.Errorf("no database type set. database type can be one of: %v", getSupportedDbs()) + } else if cfg.Database.Type != "sqlite" && cfg.Database.Type != "firestore" { + return fmt.Errorf("bad database type. database type can be one of: %v", getSupportedDbs()) + } + + if cfg.Database.Type == "sqlite" && cfg.Database.SQLite.ConnectionString == "" { + log.Printf("warn: sqlite connection string is empty") + } + + return nil } diff --git a/config/types.go b/config/types.go new file mode 100644 index 0000000..ed7a2ff --- /dev/null +++ b/config/types.go @@ -0,0 +1,35 @@ +package config + +type Config struct { + Database Database + Notifications Notifications +} + +type Database struct { + Type string `mapstructure:"type"` + Firestore Firestore + SQLite SQLite +} + +type Firestore struct { + ProjectID string `mapstructure:"project_id"` + CredentialsFile string `mapstructure:"credentials_file"` + SectionCollectionID string `mapstructure:"section_collection_id"` + WatcherCollectionID string `mapstructure:"watcher_collection_id"` +} + +type SQLite struct { + ConnectionString string `mapstructure:"connection_string"` +} + +type Notifications struct { + EmailSmtp EmailSmtp +} + +type EmailSmtp struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + From string `mapstructure:"from"` +} diff --git a/coursesense.go b/coursesense.go index aafc7f5..9f5ff95 100644 --- a/coursesense.go +++ b/coursesense.go @@ -68,12 +68,13 @@ func (w Watcher) String() string { return fmt.Sprintf("%s:%s", w.Email, w.Phone) } -// Service that manages Watchers -type WatcherService interface { +// Service that persists watched sections +type Repository interface { AddWatcher(context.Context, Section, Watcher) error GetWatchedSections(context.Context) ([]Section, error) GetWatchers(context.Context, Section) ([]Watcher, error) - RemoveWatchers(context.Context, Section) error + // This function removes a section and its watchers. It will also remove the associated course if no other sections reference it + Cleanup(context.Context, Section) error } // A type that sends can send notifications to Watchers diff --git a/go.mod b/go.mod index bda4fd5..d761b2b 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,11 @@ go 1.19 require ( cloud.google.com/go/firestore v1.10.0 + github.com/golang-migrate/migrate/v4 v4.16.2 github.com/julienschmidt/httprouter v1.3.0 github.com/spf13/viper v1.16.0 google.golang.org/api v0.126.0 + modernc.org/sqlite v1.23.1 ) require ( @@ -14,30 +16,40 @@ require ( cloud.google.com/go/compute v1.19.3 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/longrunning v0.4.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/s2a-go v0.1.4 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect github.com/googleapis/gax-go/v2 v2.10.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/afero v1.9.5 // indirect github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect go.opencensus.io v0.24.0 // indirect + go.uber.org/atomic v1.9.0 // indirect golang.org/x/crypto v0.9.0 // indirect + golang.org/x/mod v0.10.0 // indirect golang.org/x/net v0.10.0 // indirect golang.org/x/oauth2 v0.8.0 // indirect golang.org/x/sync v0.2.0 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.9.1 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect @@ -47,4 +59,13 @@ require ( google.golang.org/protobuf v1.30.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/uint128 v1.2.0 // indirect + modernc.org/cc/v3 v3.40.0 // indirect + modernc.org/ccgo/v3 v3.16.13 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/opt v0.1.3 // indirect + modernc.org/strutil v1.1.3 // indirect + modernc.org/token v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1656308..e103860 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -79,6 +81,8 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA= +github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -138,10 +142,13 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= @@ -150,6 +157,11 @@ github.com/googleapis/gax-go/v2 v2.10.0 h1:ebSgKfMxynOdxw8QQuFOKMgomqeLGPqNLQox2 github.com/googleapis/gax-go/v2 v2.10.0/go.mod h1:4UOEnMCrxsSqQ940WnTiD6qJ63le2ev3xfyagutxiPw= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -160,6 +172,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -167,8 +181,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= @@ -178,6 +196,9 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -195,6 +216,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -219,6 +241,8 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -264,6 +288,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -361,6 +387,7 @@ golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -430,6 +457,8 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= +golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -561,6 +590,30 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= +modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= +modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/migrations/sqlite/000001_init_schema.down.sql b/migrations/sqlite/000001_init_schema.down.sql new file mode 100644 index 0000000..dca5d54 --- /dev/null +++ b/migrations/sqlite/000001_init_schema.down.sql @@ -0,0 +1,3 @@ +DROP TABLE sections; +DROP TABLE watchers; +DROP TABLE courses; \ No newline at end of file diff --git a/migrations/sqlite/000001_init_schema.up.sql b/migrations/sqlite/000001_init_schema.up.sql new file mode 100644 index 0000000..72dd666 --- /dev/null +++ b/migrations/sqlite/000001_init_schema.up.sql @@ -0,0 +1,26 @@ +CREATE TABLE "courses" ( + "id" INTEGER, + "code" TEXT NOT NULL, + "department" TEXT NOT NULL, + UNIQUE("code","department"), + PRIMARY KEY("id" AUTOINCREMENT) +); + +CREATE TABLE "sections" ( + "id" INTEGER, + "code" TEXT NOT NULL, + "term" TEXT NOT NULL, + "course_id" INTEGER NOT NULL, + PRIMARY KEY("id" AUTOINCREMENT), + UNIQUE("code","term","course_id"), + FOREIGN KEY("course_id") REFERENCES "courses"("id") +); + +CREATE TABLE "watchers" ( + "id" INTEGER, + "email" TEXT NOT NULL, + "section_id" INTEGER NOT NULL, + UNIQUE("email","section_id"), + PRIMARY KEY("id" AUTOINCREMENT), + FOREIGN KEY("section_id") REFERENCES "sections"("id") +); \ No newline at end of file diff --git a/register/register.go b/register/register.go index aab5d7e..2617a0a 100644 --- a/register/register.go +++ b/register/register.go @@ -12,10 +12,10 @@ var _ coursesense.RegistrationService = Register{} type Register struct { sectionService coursesense.SectionService - watcherService coursesense.WatcherService + watcherService coursesense.Repository } -func NewRegister(s coursesense.SectionService, w coursesense.WatcherService) Register { +func NewRegister(s coursesense.SectionService, w coursesense.Repository) Register { return Register{s, w} } diff --git a/repository/factory.go b/repository/factory.go new file mode 100644 index 0000000..4262af8 --- /dev/null +++ b/repository/factory.go @@ -0,0 +1,22 @@ +package repository + +import ( + "context" + "errors" + "log" + + coursesense "github.com/jacobmichels/Course-Sense-Go" + "github.com/jacobmichels/Course-Sense-Go/config" +) + +func New(ctx context.Context, cfg config.Database) (coursesense.Repository, error) { + if cfg.Type == "firestore" { + log.Println("creating firestore repository") + return newFirestoreRepository(ctx, cfg.Firestore) + } else if cfg.Type == "sqlite" { + log.Println("creating sqlite repository") + return newSQLiteRepository(ctx, cfg.SQLite) + } else { + return nil, errors.New("invalid database type") + } +} diff --git a/firestore/firestore.go b/repository/firestore.go similarity index 58% rename from firestore/firestore.go rename to repository/firestore.go index adf5956..1246adf 100644 --- a/firestore/firestore.go +++ b/repository/firestore.go @@ -1,4 +1,4 @@ -package firestore +package repository import ( "context" @@ -7,15 +7,15 @@ import ( "cloud.google.com/go/firestore" coursesense "github.com/jacobmichels/Course-Sense-Go" + "github.com/jacobmichels/Course-Sense-Go/config" "google.golang.org/api/option" ) -var _ coursesense.WatcherService = FirestoreWatcherService{} +var _ coursesense.Repository = FirestoreRepository{} -type FirestoreWatcherService struct { - firestore *firestore.Client - sectionCollectionID string - watcherCollectionID string +type FirestoreRepository struct { + firestore *firestore.Client + cfg config.Firestore } type FirestoreWatcher struct { @@ -23,34 +23,34 @@ type FirestoreWatcher struct { SectionID string `json:"sectionID"` } -func NewFirestoreWatcherService(ctx context.Context, projectID, sectionCollectionID, watcherCollectionID, credentialsPath string) (FirestoreWatcherService, error) { +func newFirestoreRepository(ctx context.Context, cfg config.Firestore) (FirestoreRepository, error) { // Create a new Firestore client using application default credentials. - if credentialsPath == "" { - client, err := firestore.NewClient(ctx, projectID) + if cfg.CredentialsFile == "" { + client, err := firestore.NewClient(ctx, cfg.ProjectID) if err != nil { - return FirestoreWatcherService{}, err + return FirestoreRepository{}, err } - return FirestoreWatcherService{client, sectionCollectionID, watcherCollectionID}, nil + return FirestoreRepository{client, cfg}, nil } // Create a new Firestore client using supplied credentials file. - client, err := firestore.NewClient(ctx, projectID, option.WithCredentialsFile(credentialsPath)) + client, err := firestore.NewClient(ctx, cfg.ProjectID, option.WithCredentialsFile(cfg.CredentialsFile)) if err != nil { - return FirestoreWatcherService{}, err + return FirestoreRepository{}, err } - return FirestoreWatcherService{client, sectionCollectionID, watcherCollectionID}, nil + return FirestoreRepository{client, cfg}, nil } -func (f FirestoreWatcherService) AddWatcher(ctx context.Context, section coursesense.Section, watcher coursesense.Watcher) error { +func (f FirestoreRepository) AddWatcher(ctx context.Context, section coursesense.Section, watcher coursesense.Watcher) error { // Steps: // 1. Retrieve the section document, creating it if it doesn't exist // 2. Inspect the current watchers. If the new watcher is already a watcher, stop and return a nil error // 3. Append the new watcher to the watchers array // 4. Update the document in the collection - documents, err := f.firestore.Collection(f.sectionCollectionID).Where("Code", "==", section.Code).Where("Term", "==", section.Term).Where("Course.Code", "==", section.Course.Code).Where("Course.Department", "==", section.Course.Department).Documents(ctx).GetAll() + documents, err := f.firestore.Collection(f.cfg.SectionCollectionID).Where("Code", "==", section.Code).Where("Term", "==", section.Term).Where("Course.Code", "==", section.Course.Code).Where("Course.Department", "==", section.Course.Department).Documents(ctx).GetAll() if err != nil { return fmt.Errorf("failed to get matching section documents: %w", err) } @@ -61,7 +61,7 @@ func (f FirestoreWatcherService) AddWatcher(ctx context.Context, section courses var sectionID string if len(documents) == 0 { - ref, _, err := f.firestore.Collection(f.sectionCollectionID).Add(ctx, section) + ref, _, err := f.firestore.Collection(f.cfg.SectionCollectionID).Add(ctx, section) if err != nil { return fmt.Errorf("failed to add %s to collection: %w", section, err) } @@ -70,7 +70,7 @@ func (f FirestoreWatcherService) AddWatcher(ctx context.Context, section courses sectionID = documents[0].Ref.ID } - documents, err = f.firestore.Collection(f.watcherCollectionID).Where("SectionID", "==", sectionID).Documents(ctx).GetAll() + documents, err = f.firestore.Collection(f.cfg.WatcherCollectionID).Where("SectionID", "==", sectionID).Documents(ctx).GetAll() if err != nil { return fmt.Errorf("failed to get matching watcher documents: %w", err) } @@ -89,7 +89,7 @@ func (f FirestoreWatcherService) AddWatcher(ctx context.Context, section courses } newWatcher := FirestoreWatcher{Watcher: watcher, SectionID: sectionID} - _, _, err = f.firestore.Collection(f.watcherCollectionID).Add(ctx, newWatcher) + _, _, err = f.firestore.Collection(f.cfg.WatcherCollectionID).Add(ctx, newWatcher) if err != nil { return fmt.Errorf("failed to write new watcher to collection: %w", err) } @@ -97,8 +97,8 @@ func (f FirestoreWatcherService) AddWatcher(ctx context.Context, section courses return nil } -func (f FirestoreWatcherService) GetWatchedSections(ctx context.Context) ([]coursesense.Section, error) { - documents, err := f.firestore.Collection(f.sectionCollectionID).Documents(ctx).GetAll() +func (f FirestoreRepository) GetWatchedSections(ctx context.Context) ([]coursesense.Section, error) { + documents, err := f.firestore.Collection(f.cfg.SectionCollectionID).Documents(ctx).GetAll() if err != nil { return nil, fmt.Errorf("failed to get all documents in sections collection: %w", err) } @@ -117,8 +117,8 @@ func (f FirestoreWatcherService) GetWatchedSections(ctx context.Context) ([]cour return results, nil } -func (f FirestoreWatcherService) GetWatchers(ctx context.Context, section coursesense.Section) ([]coursesense.Watcher, error) { - documents, err := f.firestore.Collection(f.sectionCollectionID).Where("Code", "==", section.Code).Where("Term", "==", section.Term).Where("Course.Code", "==", section.Course.Code).Where("Course.Department", "==", section.Course.Department).Documents(ctx).GetAll() +func (f FirestoreRepository) GetWatchers(ctx context.Context, section coursesense.Section) ([]coursesense.Watcher, error) { + documents, err := f.firestore.Collection(f.cfg.SectionCollectionID).Where("Code", "==", section.Code).Where("Term", "==", section.Term).Where("Course.Code", "==", section.Course.Code).Where("Course.Department", "==", section.Course.Department).Documents(ctx).GetAll() if err != nil { return nil, fmt.Errorf("failed to get matching section documents: %w", err) } @@ -134,7 +134,7 @@ func (f FirestoreWatcherService) GetWatchers(ctx context.Context, section course sectionID := documents[0].Ref.ID - documents, err = f.firestore.Collection(f.watcherCollectionID).Where("SectionID", "==", sectionID).Documents(ctx).GetAll() + documents, err = f.firestore.Collection(f.cfg.WatcherCollectionID).Where("SectionID", "==", sectionID).Documents(ctx).GetAll() if err != nil { return nil, fmt.Errorf("failed to get matching watcher documents: %w", err) } @@ -153,8 +153,8 @@ func (f FirestoreWatcherService) GetWatchers(ctx context.Context, section course return results, nil } -func (f FirestoreWatcherService) RemoveWatchers(ctx context.Context, section coursesense.Section) error { - documents, err := f.firestore.Collection(f.sectionCollectionID).Where("Code", "==", section.Code).Where("Term", "==", section.Term).Where("Course.Code", "==", section.Course.Code).Where("Course.Department", "==", section.Course.Department).Documents(ctx).GetAll() +func (f FirestoreRepository) Cleanup(ctx context.Context, section coursesense.Section) error { + documents, err := f.firestore.Collection(f.cfg.SectionCollectionID).Where("Code", "==", section.Code).Where("Term", "==", section.Term).Where("Course.Code", "==", section.Course.Code).Where("Course.Department", "==", section.Course.Department).Documents(ctx).GetAll() if err != nil { return fmt.Errorf("failed to get matching section documents: %w", err) } @@ -174,7 +174,7 @@ func (f FirestoreWatcherService) RemoveWatchers(ctx context.Context, section cou return fmt.Errorf("failed to delete section: %w", err) } - documents, err = f.firestore.Collection(f.watcherCollectionID).Where("SectionID", "==", sectionID).Documents(ctx).GetAll() + documents, err = f.firestore.Collection(f.cfg.WatcherCollectionID).Where("SectionID", "==", sectionID).Documents(ctx).GetAll() if err != nil { return fmt.Errorf("failed to get matching watcher documents: %w", err) } diff --git a/repository/sqlite.go b/repository/sqlite.go new file mode 100644 index 0000000..828ab05 --- /dev/null +++ b/repository/sqlite.go @@ -0,0 +1,264 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/sqlite" + _ "github.com/golang-migrate/migrate/v4/source/file" + coursesense "github.com/jacobmichels/Course-Sense-Go" + "github.com/jacobmichels/Course-Sense-Go/config" + _ "modernc.org/sqlite" +) + +var _ coursesense.Repository = SQLiteRepository{} + +type SQLiteRepository struct { + db *sql.DB + cfg config.SQLite +} + +// creates a new repository backed by sqlite +// returns an error if the connection cannot be established or if a ping fails +func newSQLiteRepository(ctx context.Context, cfg config.SQLite) (SQLiteRepository, error) { + // open connection + db, err := sql.Open("sqlite", cfg.ConnectionString) + if err != nil { + return SQLiteRepository{}, fmt.Errorf("failed to open connection to sqlite: %w", err) + } + + // check connection + err = db.PingContext(ctx) + if err != nil { + return SQLiteRepository{}, fmt.Errorf("failed to ping db: %w", err) + } + + // perform migrations + driver, err := sqlite.WithInstance(db, &sqlite.Config{}) + if err != nil { + return SQLiteRepository{}, fmt.Errorf("failed to create migration driver: %w", err) + } + + m, err := migrate.NewWithDatabaseInstance("file://migrations/sqlite", "sqlite", driver) + if err != nil { + return SQLiteRepository{}, fmt.Errorf("failed to create migration: %w", err) + } + + err = m.Up() + if err != nil && err.Error() != "no change" { + return SQLiteRepository{}, fmt.Errorf("failed to execute migrations: %w", err) + } + + return SQLiteRepository{db, cfg}, nil +} + +func (r SQLiteRepository) AddWatcher(ctx context.Context, section coursesense.Section, watcher coursesense.Watcher) error { + txCtx, cancel := context.WithCancel(ctx) + defer cancel() + + tx, err := r.db.BeginTx(txCtx, &sql.TxOptions{}) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + // first insert the course + course_id, err := persistCourse(txCtx, tx, section.Course) + if err != nil { + return fmt.Errorf("failed to persist course: %w", err) + } + + // then insert the section + section_id, err := persistSection(txCtx, tx, section, course_id) + if err != nil { + return fmt.Errorf("failed to persist section: %w", err) + } + + // finally we can insert the watcher and commit the transaction + err = persistWatcher(txCtx, tx, watcher, section_id) + if err != nil { + return fmt.Errorf("failed to persist watcher: %w", err) + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +// insert a course into sqlite if needed +// returns the course_id +func persistCourse(txCtx context.Context, tx *sql.Tx, course coursesense.Course) (int, error) { + // check if identical course already exists in db + var course_id int + err := tx.QueryRowContext(txCtx, "SELECT id FROM courses WHERE code=$1 AND department=$2", course.Code, course.Department).Scan(&course_id) + if err != nil && errors.Is(err, sql.ErrNoRows) { + // if it doesn't exist, insert it and return the new rowid + res, err := tx.ExecContext(txCtx, "INSERT INTO courses (code, department) VALUES ($1, $2)", course.Code, course.Department) + if err != nil { + return 0, fmt.Errorf("insert statement failed: %w", err) + } + + course_id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to fetch inserted course id: %w", err) + } + return int(course_id), nil + } else if err != nil { + return 0, fmt.Errorf("failed to check if course already exists in database: %w", err) + } + + // simply return the rowid if the course does exist in the db + return course_id, nil +} + +// insert a section into sqlite if needed +// returns the section_id +func persistSection(txCtx context.Context, tx *sql.Tx, section coursesense.Section, course_id int) (int, error) { + // check if identical section already exists in db + var section_id int + err := tx.QueryRowContext(txCtx, "SELECT id FROM sections WHERE code=$1 AND term=$2 AND course_id=$3", section.Code, section.Term, course_id).Scan(§ion_id) + if err != nil && errors.Is(err, sql.ErrNoRows) { + // if it doesn't exist, insert it and return the new rowid + res, err := tx.ExecContext(txCtx, "INSERT INTO sections (code, term, course_id) VALUES ($1, $2, $3)", section.Code, section.Term, course_id) + if err != nil { + return 0, fmt.Errorf("insert statement failed: %w", err) + } + + section_id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to fetch inserted section id: %w", err) + } + return int(section_id), nil + } else if err != nil { + return 0, fmt.Errorf("failed to check if section already exists in database: %w", err) + } + + // simply return the rowid if the course does exist in the db + return section_id, nil +} + +// insert a watcher into sqlite if needed +func persistWatcher(txCtx context.Context, tx *sql.Tx, watcher coursesense.Watcher, section_id int) error { + // check if identical watcher already exists in db + var watcher_id int + err := tx.QueryRowContext(txCtx, "SELECT id FROM watchers WHERE email=$1 AND section_id=$2", watcher.Email, section_id).Scan(&watcher_id) + if err != nil && errors.Is(err, sql.ErrNoRows) { + // if it doesn't exist, insert it + _, err := tx.ExecContext(txCtx, "INSERT INTO watchers (email, section_id) VALUES ($1, $2)", watcher.Email, section_id) + if err != nil { + return fmt.Errorf("insert statement failed: %w", err) + } + } else if err != nil { + return fmt.Errorf("failed to check if watcher already exists in database: %w", err) + } + return nil +} + +func (r SQLiteRepository) GetWatchedSections(ctx context.Context) ([]coursesense.Section, error) { + rows, err := r.db.QueryContext(ctx, "SELECT courses.code, courses.department, sections.code, sections.term FROM sections left join courses on sections.course_id=courses.id") + if err != nil { + return nil, fmt.Errorf("failed to fetch sections from the db: %w", err) + } + + var sections []coursesense.Section + + defer rows.Close() + for rows.Next() { + var section coursesense.Section + + if err := rows.Scan(§ion.Course.Code, §ion.Course.Department, §ion.Code, §ion.Term); err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + sections = append(sections, section) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate rows: %w", err) + } + + return sections, nil +} + +func (r SQLiteRepository) GetWatchers(ctx context.Context, section coursesense.Section) ([]coursesense.Watcher, error) { + // get section_id for section in question + var section_id int + err := r.db.QueryRowContext(ctx, "SELECT sections.id FROM sections left join courses on sections.course_id=courses.id WHERE sections.code=$1 AND sections.term=$2 AND courses.department=$3 AND courses.code=$4", section.Code, section.Term, section.Course.Department, section.Course.Code).Scan(§ion_id) + if err != nil { + return nil, fmt.Errorf("failed to get section_id from db: %w", err) + } + + // then get the emails + rows, err := r.db.QueryContext(ctx, "SELECT email FROM watchers WHERE section_id=$1", section_id) + if err != nil { + return nil, fmt.Errorf("failed to fetch relevant watchers from db: %w", err) + } + + var watchers []coursesense.Watcher + + defer rows.Close() + for rows.Next() { + var watcher coursesense.Watcher + + if err := rows.Scan(&watcher.Email); err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + watchers = append(watchers, watcher) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate rows: %w", err) + } + + return watchers, nil +} + +func (r SQLiteRepository) Cleanup(ctx context.Context, section coursesense.Section) error { + // start by removing watchers + // get section_id for section in question + var section_id int + err := r.db.QueryRowContext(ctx, "SELECT sections.id FROM sections left join courses on sections.course_id=courses.id WHERE sections.code=$1 AND sections.term=$2 AND courses.department=$3 AND courses.code=$4", section.Code, section.Term, section.Course.Department, section.Course.Code).Scan(§ion_id) + if err != nil { + return fmt.Errorf("failed to get section_id from db: %w", err) + } + + _, err = r.db.ExecContext(ctx, "DELETE FROM watchers WHERE section_id=$1", section_id) + if err != nil { + return fmt.Errorf("failed to execute delete command: %w", err) + } + + // get the id of the course referenced by the section + var course_id int + err = r.db.QueryRowContext(ctx, "SELECT courses.id FROM courses LEFT JOIN sections ON courses.id=sections.course_id WHERE courses.code=$1 AND courses.department=$2 AND sections.code=$3 AND sections.term=$4", section.Course.Code, section.Course.Department, section.Code, section.Term).Scan(&course_id) + if err != nil { + return fmt.Errorf("failed to fetch course_id from db: %w", err) + } + + // remove the related row in the section table + _, err = r.db.ExecContext(ctx, "DELETE FROM sections WHERE id=$1", section_id) + if err != nil { + return fmt.Errorf("failed to delete section: %w", err) + } + + // check if there are any other sections of the same course in the table + var count int + err = r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sections WHERE course_id=$1", course_id).Scan(&count) + if err != nil { + return fmt.Errorf("failed to count same course sections: %w", err) + } + + // if no other sections reference this course, we can safely delete it + if count == 0 { + log.Printf("deleting course %v %v", section.Course.Department, section.Course.Code) + _, err = r.db.ExecContext(ctx, "DELETE FROM courses WHERE id=$1", course_id) + if err != nil { + return fmt.Errorf("failed to delete course: %w", err) + } + } + + return nil +} diff --git a/trigger/trigger.go b/trigger/trigger.go index b6f7b89..9b3ca4f 100644 --- a/trigger/trigger.go +++ b/trigger/trigger.go @@ -13,11 +13,11 @@ var _ coursesense.TriggerService = Trigger{} type Trigger struct { sectionService coursesense.SectionService - watcherService coursesense.WatcherService + watcherService coursesense.Repository notifiers []coursesense.Notifier } -func NewTrigger(s coursesense.SectionService, w coursesense.WatcherService, n ...coursesense.Notifier) Trigger { +func NewTrigger(s coursesense.SectionService, w coursesense.Repository, n ...coursesense.Notifier) Trigger { return Trigger{s, w, n} } @@ -63,7 +63,7 @@ func (t Trigger) Trigger(ctx context.Context) error { } } - if err := t.watcherService.RemoveWatchers(ctx, section); err != nil { + if err := t.watcherService.Cleanup(ctx, section); err != nil { return fmt.Errorf("failed to remove watchers from %s: %w", section, err) } }