diff --git a/.golangci.yml b/.golangci.yml index 512b895ef..967ba5d1a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,5 @@ run: - timeout: 5m + timeout: 15m tests: true linters: diff --git a/config.yml.sample b/config.yml.sample index 250610660..9851e4e72 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -136,6 +136,10 @@ log: http: "stdout" # Echo has its own logging which usually is unnessecary, which is why it is disabled by default. Possible values are stdout, stderr, file or off to disable standard logging. echo: "off" + # Whether or not to log events. Useful for debugging. Possible values are stdout, stderr, file or off to disable events logging. + events: "stdout" + # The log level for event log messages. Possible values (case-insensitive) are ERROR, INFO, DEBUG. + eventslevel: "info" ratelimit: # whether or not to enable the rate limit diff --git a/docs/content/doc/development/events-and-listeners.md b/docs/content/doc/development/events-and-listeners.md new file mode 100644 index 000000000..6886cd0ce --- /dev/null +++ b/docs/content/doc/development/events-and-listeners.md @@ -0,0 +1,195 @@ +--- +date: 2018-10-13T19:26:34+02:00 +title: "Events and Listeners" +draft: false +menu: + sidebar: + parent: "development" +--- + +# Events and Listeners + +Vikunja provides a simple observer pattern mechanism through events and listeners. +The basic principle of events is always the same: Something happens (=An event is fired) and something reacts to it (=A listener is called). + +Vikunja supports this principle through the `events` package. +It is built upon the excellent [watermill](https://watermill.io) library. + +Currently, it only supports dispatching events through Go Channels which makes it configuration-less. +More methods of dispatching events (like kafka or rabbitmq) are available in watermill and could be enabled with a PR. + +This document explains how events and listeners work in Vikunja, how to use them and how to create new ones. + +{{< table_of_contents >}} + +## Events + +### Definition + +Each event has to implement this interface: + +```golang +type Event interface { + Name() string +} +``` + +An event can contain whatever data you need. + +When an event is dispatched, all of the data it contains will be marshaled into json for dispatching. +You then get the event with all its data back in the listener, see below. + +#### Naming Convention + +Event names should roughly have the entity they're dealing with on the left and the action on the right of the name, separated by `.`. +There's no limit to how "deep" or specifig an event name can be. + +The name should have the most general concept it's describing at the left, getting more specific on the right of it. + +#### Location + +All events for a package should be declared in the `events.go` file of that package. + +### Creating a New Event + +The easiest way to create a new event is to generate it with mage: + +``` +mage dev:make-event +``` + +The function takes the name of the event as the first argument and the package where the event should be created as the second argument. +Events will be appended to the `pkg//events.go` file. +Both parameters are mandatory. + +The event type name is automatically camel-cased and gets the `Event` suffix if the provided name does not already have one. +The event name is derived from the type name and stripped of the `.event` suffix. + +The generated event will look something like the example below. + +### Dispatching events + +To dispatch an event, simply call the `events.Dispatch` method and pass in the event as parameter. + +### Example + +The `TaskCreatedEvent` is declared in the `pkg/models/events.go` file as follows: + +```golang +// TaskCreatedEvent represents an event where a task has been created +type TaskCreatedEvent struct { + Task *Task + Doer web.Auth +} + +// Name defines the name for TaskCreatedEvent +func (t *TaskCreatedEvent) Name() string { + return "task.created" +} +``` + +It is dispatched in the `createTask` function of the `models` package: + +```golang +func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err error) { + + // ... + + err = events.Dispatch(&TaskCreatedEvent{ + Task: t, + Doer: a, + }) + + // ... +} +``` + +As you can see, the curent task and doer are injected into it. + +### Special Events + +#### `BootedEvent` + +Once Vikunja is fully initialized, right before the api web server is started, this event is fired. + +## Listeners + +A listener is a piece of code that gets executed asynchronously when an event is dispatched. + +A single event can have multiple listeners who are independent of each other. + +### Definition + +All listeners must implement this interface: + +```golang +// Listener represents something that listens to events +type Listener interface { + Handle(payload message.Payload) error + Name() string +} +``` + +The `Handle` method is executed when the event this listener listens on is dispatched. +* As the single parameter, it gets the payload of the event, which is the event struct when it was dispatched decoded as json object and passed as a slice of bytes. +To use it you'll need to unmarshal it. Unfortunately there's no way to pass an already populated event object to the function because we would not know what type it has when parsing it. +* If the handler returns an error, the listener is retried 5 times, with an exponentional back-off period in between retries. +If it still fails after the fifth retry, the event is nack'd and it's up to the event dispatcher to resend it. +You can learn more about this mechanism in the [watermill documentation](https://watermill.io/docs/middlewares/#retry). + +The `Name` method needs to return a unique listener name for this listener. +It should follow the same convention as event names, see above. + +### Creating a New Listener + +The easiest way to create a new listener for an event is with mage: + +``` +mage dev:make-listener +``` + +This will create a new listener type in the `pkg//listners.go` file and implement the `Handle` and `Name` methods. +It will also pre-generate some boilerplate code to unmarshal the event from the payload. + +Furthermore, it will register the listener for its event in the `RegisterListeners()` method of the same file. +This function is called at startup and has to contain all events you want to listen for. + +### Listening for Events + +To listen for an event, you need to register the listener for the event it should be called for. +This usually happens in the `RegisterListeners()` method in `pkg//listners.go` which is called at start up. + +The listener will never be executed if it hasn't been registered. + +See the example below. + +### Example + +```golang +// RegisterListeners registers all event listeners +func RegisterListeners() { + events.RegisterListener((&ListCreatedEvent{}).Name(), &IncreaseListCounter{}) +} + +// IncreaseTaskCounter represents a listener +type IncreaseTaskCounter struct {} + +// Name defines the name for the IncreaseTaskCounter listener +func (s *IncreaseTaskCounter) Name() string { + return "task.counter.increase" +} + +// Hanlde is executed when the event IncreaseTaskCounter listens on is fired +func (s *IncreaseTaskCounter) Handle(payload message.Payload) (err error) { + return keyvalue.IncrBy(metrics.TaskCountKey, 1) +} +``` + +## Testing + +When testing, you should call the `events.Fake()` method in the `TestMain` function of the package you want to test. +This prevents any events from being fired and lets you assert an event has been dispatched like so: + +```golang +events.AssertDispatched(t, &TaskCreatedEvent{}) +``` diff --git a/docs/content/doc/setup/config.md b/docs/content/doc/setup/config.md index 4850c9f20..85c561e31 100644 --- a/docs/content/doc/setup/config.md +++ b/docs/content/doc/setup/config.md @@ -446,6 +446,18 @@ Echo has its own logging which usually is unnessecary, which is why it is disabl Default: `off` +### events + +Whether or not to log events. Useful for debugging. Possible values are stdout, stderr, file or off to disable events logging. + +Default: `stdout` + +### eventslevel + +The log level for event log messages. Possible values (case-insensitive) are ERROR, INFO, DEBUG. + +Default: `info` + --- ## ratelimit diff --git a/docs/themes/vikunja b/docs/themes/vikunja index 958219fc8..1ebcbbb64 160000 --- a/docs/themes/vikunja +++ b/docs/themes/vikunja @@ -1 +1 @@ -Subproject commit 958219fc84db455ed58d7a4380bbffc8d04fd5cf +Subproject commit 1ebcbbb645ad20ea683feef2804314a6c658799b diff --git a/go.mod b/go.mod index 6a6a9ab21..9df054bb0 100644 --- a/go.mod +++ b/go.mod @@ -18,9 +18,9 @@ module code.vikunja.io/api require ( 4d63.com/tz v1.2.0 - code.vikunja.io/web v0.0.0-20201223143420-588abb73703a - dmitri.shuralyov.com/go/generated v0.0.0-20170818220700-b1254a446363 // indirect + code.vikunja.io/web v0.0.0-20210131201003-26386be9a9ae gitea.com/xorm/xorm-redis-cache v0.2.0 + github.com/ThreeDotsLabs/watermill v1.1.1 github.com/adlio/trello v1.8.0 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef @@ -28,7 +28,6 @@ require ( github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2 github.com/client9/misspell v0.3.4 github.com/coreos/go-oidc v2.2.1+incompatible - github.com/coreos/go-oidc/v3 v3.0.0 github.com/cweill/gotests v1.6.0 github.com/d4l3k/messagediff v1.2.1 github.com/dgrijalva/jwt-go v3.2.0+incompatible @@ -37,7 +36,7 @@ require ( github.com/fzipp/gocyclo v0.3.1 github.com/gabriel-vasile/mimetype v1.1.2 github.com/getsentry/sentry-go v0.9.0 - github.com/go-errors/errors v1.1.1 + github.com/go-errors/errors v1.1.1 // indirect github.com/go-redis/redis/v8 v8.4.11 github.com/go-sql-driver/mysql v1.5.0 github.com/go-testfixtures/testfixtures/v3 v3.5.0 @@ -82,8 +81,9 @@ require ( golang.org/x/net v0.0.0-20201216054612-986b41b23924 // indirect golang.org/x/oauth2 v0.0.0-20210201163806-010130855d6c golang.org/x/sync v0.0.0-20201207232520-09787c993a3a - golang.org/x/sys v0.0.0-20201223074533-0d417f636930 // indirect + golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf + golang.org/x/text v0.3.5 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/d4l3k/messagediff.v1 v1.2.1 @@ -94,9 +94,9 @@ require ( honnef.co/go/tools v0.0.1-2020.1.5 src.techknowlogick.com/xgo v1.2.1-0.20201205054505-b97762e7a76b src.techknowlogick.com/xormigrate v1.4.0 - xorm.io/builder v0.3.7 + xorm.io/builder v0.3.8 xorm.io/core v0.7.3 - xorm.io/xorm v1.0.5 + xorm.io/xorm v1.0.7 ) replace ( diff --git a/go.sum b/go.sum index 9ca35f9b0..ec3307a0f 100644 --- a/go.sum +++ b/go.sum @@ -38,14 +38,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -code.vikunja.io/web v0.0.0-20201218134444-505d0e77fac7 h1:iS3TFA+y1If6DEbqzad5Ge7TI1NxZr9BevC/dU4ygEo= -code.vikunja.io/web v0.0.0-20201218134444-505d0e77fac7/go.mod h1:vDWiCtftF6LNCCrem7mjstPWMgzLUvMW/L4YwIQ1Voo= -code.vikunja.io/web v0.0.0-20201222144643-6fa2fb587215 h1:O5zMWgcnVDVLaQUawgdsv/jX/4SUUAvSedvRR+5+x2o= -code.vikunja.io/web v0.0.0-20201222144643-6fa2fb587215/go.mod h1:OgFO06HN1KpA4S7Dw/QAIeygiUPSeGJJn1ykz/sjZdU= -code.vikunja.io/web v0.0.0-20201223143420-588abb73703a h1:LaWCucY5Pp30EIMgGOvdVFNss5OhIAwrAO8PuFVRUfw= -code.vikunja.io/web v0.0.0-20201223143420-588abb73703a/go.mod h1:OgFO06HN1KpA4S7Dw/QAIeygiUPSeGJJn1ykz/sjZdU= -dmitri.shuralyov.com/go/generated v0.0.0-20170818220700-b1254a446363 h1:o4lAkfETerCnr1kF9/qwkwjICnU+YLHNDCM8h2xj7as= -dmitri.shuralyov.com/go/generated v0.0.0-20170818220700-b1254a446363/go.mod h1:WG7q7swWsS2f9PYpt5DoEP/EBYWx8We5UoRltn9vJl8= +code.vikunja.io/web v0.0.0-20210131201003-26386be9a9ae h1:qqgwoWjKrpIOdrIR0FPawiHLZTRYwS9MBgwH1eZJJqA= +code.vikunja.io/web v0.0.0-20210131201003-26386be9a9ae/go.mod h1:OgFO06HN1KpA4S7Dw/QAIeygiUPSeGJJn1ykz/sjZdU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= @@ -72,6 +66,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/ThreeDotsLabs/watermill v1.1.1 h1:+9NXqWQvplzxBru2CIInvVOZeKUnM+Nysg42fInl5sY= +github.com/ThreeDotsLabs/watermill v1.1.1/go.mod h1:Qd1xNFxolCAHCzcMrm6RnjW0manbvN+DJVWc1MWRFlI= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= @@ -111,7 +107,10 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2 h1:t8KYCwSKsOEZBFELI4Pn/phbp38iJ1RRAkDFNin1aak= github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c= +github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -134,7 +133,6 @@ github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= -github.com/coreos/go-oidc/v3 v3.0.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= @@ -147,8 +145,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/cweill/gotests v1.5.3 h1:k3t4wW/x/YNixWZJhUIn+mivmK5iV1tJVOwVYkx0UcU= -github.com/cweill/gotests v1.5.3/go.mod h1:XZYOJkGVkCRoymaIzmp9Wyi3rUgfA3oOnkuljYrjFV8= github.com/cweill/gotests v1.6.0 h1:KJx+/p4EweijYzqPb4Y/8umDCip1Cv6hEVyOx0mE9W8= github.com/cweill/gotests v1.6.0/go.mod h1:CaRYbxQZGQOxXDvM9l0XJVV2Tjb2E5H53vq+reR2GrA= github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U= @@ -211,6 +207,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= +github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.1.1 h1:ljK/pL5ltg3qoN+OtN6yCv9HWSfMwxSx90GJCZQxYNg= @@ -241,18 +239,6 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-redis/redis/v8 v8.4.2 h1:gKRo1KZ+O3kXRfxeRblV5Tr470d2YJZJVIAv2/S8960= github.com/go-redis/redis/v8 v8.4.2/go.mod h1:A1tbYoHSa1fXwN+//ljcCYYJeLmVrwL9hbQN45Jdy0M= -github.com/go-redis/redis/v8 v8.4.4 h1:fGqgxCTR1sydaKI00oQf3OmkU/DIe/I/fYXvGklCIuc= -github.com/go-redis/redis/v8 v8.4.4/go.mod h1:nA0bQuF0i5JFx4Ta9RZxGKXFrQ8cRWntra97f0196iY= -github.com/go-redis/redis/v8 v8.4.6 h1:a4i+zYK6Mq2A2qAz0R6n6xo9dfnh7HdlRU/QvrlpfPI= -github.com/go-redis/redis/v8 v8.4.6/go.mod h1:nA0bQuF0i5JFx4Ta9RZxGKXFrQ8cRWntra97f0196iY= -github.com/go-redis/redis/v8 v8.4.7 h1:l0/Hkj3HLA46eJSvHGLhnF+KHBrmsyipK84ycJXFZxw= -github.com/go-redis/redis/v8 v8.4.7/go.mod h1:nA0bQuF0i5JFx4Ta9RZxGKXFrQ8cRWntra97f0196iY= -github.com/go-redis/redis/v8 v8.4.8 h1:sEG4g6Jq4hvQzbrNsVDNTDdxFCUnFC0jxuOp6tgALlA= -github.com/go-redis/redis/v8 v8.4.8/go.mod h1:/cTZsrSn1DPqRuOnSDuyH2OSvd9iX0iUGT0s7hYGIAg= -github.com/go-redis/redis/v8 v8.4.9 h1:ixEQSxNnzo6zh/dmoZIHl9DmyX3mHV5a2p6OasPR93k= -github.com/go-redis/redis/v8 v8.4.9/go.mod h1:d5yY/TlkQyYBSBHnXUmnf1OrHbyQere5JV4dLKwvXmo= -github.com/go-redis/redis/v8 v8.4.10 h1:fWdl0RBmVibUDOp8bqz1e2Yy9dShOeIeWsiAifYk06Y= -github.com/go-redis/redis/v8 v8.4.10/go.mod h1:d5yY/TlkQyYBSBHnXUmnf1OrHbyQere5JV4dLKwvXmo= github.com/go-redis/redis/v8 v8.4.11 h1:t2lToev01VTrqYQcv+QFbxtGgcf64K+VUMgf9Ap6A/E= github.com/go-redis/redis/v8 v8.4.11/go.mod h1:d5yY/TlkQyYBSBHnXUmnf1OrHbyQere5JV4dLKwvXmo= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -261,8 +247,6 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-testfixtures/testfixtures/v3 v3.4.1 h1:Qz9y0wUOXPHzKhK6C79A/menChtEu/xd0Dn5ngVyMD0= -github.com/go-testfixtures/testfixtures/v3 v3.4.1/go.mod h1:P4L3WxgOsCLbAeUC50qX5rdj1ULZfUMqgCbqah3OH5U= github.com/go-testfixtures/testfixtures/v3 v3.5.0 h1:fFJGHhFdcwy48oTLHvr0WRQ09rGiZE+as9ElvbRWS+c= github.com/go-testfixtures/testfixtures/v3 v3.5.0/go.mod h1:P4L3WxgOsCLbAeUC50qX5rdj1ULZfUMqgCbqah3OH5U= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= @@ -346,12 +330,12 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gordonklaus/ineffassign v0.0.0-20201107091007-3b93a8888063 h1:dKprcOvlsvqfWn/iGvz+oYuC2axESeSMuF8dDrWMNsE= -github.com/gordonklaus/ineffassign v0.0.0-20201107091007-3b93a8888063/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= github.com/gordonklaus/ineffassign v0.0.0-20210104184537-8eed68eb605f h1:wHGrcNkjqm/QJeljJ9bkFWtND9I0ASarwpBlRcwRymk= github.com/gordonklaus/ineffassign v0.0.0-20210104184537-8eed68eb605f/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= @@ -369,10 +353,12 @@ github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBt github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= @@ -390,8 +376,6 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= -github.com/iancoleman/strcase v0.1.2 h1:gnomlvw9tnV3ITTAxzKSgTF+8kFWcU/f+TgttpXGz1U= -github.com/iancoleman/strcase v0.1.2/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= github.com/iancoleman/strcase v0.1.3 h1:dJBk1m2/qjL1twPLf68JND55vvivMupZ4wIzE8CTdBw= github.com/iancoleman/strcase v0.1.3/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -526,9 +510,9 @@ github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8= github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/lithammer/shortuuid/v3 v3.0.4 h1:uj4xhotfY92Y1Oa6n6HUiFn87CdoEHYUlTy0+IgbLrs= +github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= -github.com/magefile/mage v1.10.0 h1:3HiXzCUY12kh9bIuyXShaVe529fJfyqoVM42o/uom2g= -github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= @@ -570,8 +554,6 @@ github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/ github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI= github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= -github.com/mattn/go-sqlite3 v1.14.5 h1:1IdxlwTNazvbKJQSxoJ5/9ECbEeaTTyeU7sEAZ5KKTQ= -github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= @@ -612,6 +594,7 @@ github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= @@ -859,8 +842,6 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opentelemetry.io/otel v0.14.0 h1:YFBEfjCk9MTjaytCNSUkp9Q8lF7QJezA06T71FbQxLQ= go.opentelemetry.io/otel v0.14.0/go.mod h1:vH5xEuwy7Rts0GNtsCW3HYQoZDY+OmBJ6t1bFGGlxgw= -go.opentelemetry.io/otel v0.15.0 h1:CZFy2lPhxd4HlhZnYK8gRyDotksO3Ip9rBweY1vVYJw= -go.opentelemetry.io/otel v0.15.0/go.mod h1:e4GKElweB8W2gWUqbghw0B8t5MCTccc9212eNHnOHwA= go.opentelemetry.io/otel v0.16.0 h1:uIWEbdeb4vpKPGITLsRVUS44L5oDbDUCZxn8lkxhmgw= go.opentelemetry.io/otel v0.16.0/go.mod h1:e4GKElweB8W2gWUqbghw0B8t5MCTccc9212eNHnOHwA= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -965,7 +946,6 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -990,18 +970,6 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7O golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5 h1:Lm4OryKCca1vehdsWogr9N4t7NfZxLbJoc/H0w4K4S4= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210112200429-01de73cf58bd h1:0n2rzLq6xLtV9OFaT0BF2syUkjOwRrJ1zvXY5hH7Kkc= -golang.org/x/oauth2 v0.0.0-20210112200429-01de73cf58bd/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210113160501-8b1d76fa0423 h1:/hEknzWkMPCjTo7StMHRrBRa8YBbXuBWfck8680k3RE= -golang.org/x/oauth2 v0.0.0-20210113160501-8b1d76fa0423/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3 h1:BaN3BAqnopnKjvl+15DYP6LLrbBHfbfmlFYzmFj/Q9Q= -golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210125201302-af13f521f196 h1:w0u30BeG/TALEc6xVf1Klaz2+etRR4K6jxhRkWCqt4g= -golang.org/x/oauth2 v0.0.0-20210125201302-af13f521f196/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210126194326-f9ce19ea3013 h1:55H5j7lotzuFCEOKDsMch+fRNUQ9DgtyHOUP31FNqKc= -golang.org/x/oauth2 v0.0.0-20210126194326-f9ce19ea3013/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210201163806-010130855d6c h1:HiAZXo96zOhVhtFHchj/ojzoxCFiPrp9/j0GtS38V3g= golang.org/x/oauth2 v0.0.0-20210201163806-010130855d6c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1080,10 +1048,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuF golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs= golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201221093633-bc327ba9c2f0 h1:n+DPcgTwkgWzIFpLmoimYR2K2b0Ga5+Os4kayIN0vGo= -golang.org/x/sys v0.0.0-20201221093633-bc327ba9c2f0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201223074533-0d417f636930 h1:vRgIt+nup/B/BwIS0g2oC0haq0iqbV3ZA+u6+0TlNCo= -golang.org/x/sys v0.0.0-20201223074533-0d417f636930/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1099,6 +1065,8 @@ golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1122,8 +1090,6 @@ golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628034336-212fb13d595e h1:ZlQjfVdpDxeqxRfmO30CdqWWzTvgRCj0MxaUVfxEG1k= -golang.org/x/tools v0.0.0-20190628034336-212fb13d595e/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -1336,11 +1302,13 @@ src.techknowlogick.com/xormigrate v1.4.0 h1:gAfLoDwcVfMiFhSXg5Qwm7LNnG1iUbBVDUNf src.techknowlogick.com/xormigrate v1.4.0/go.mod h1:xCtbAK00lJ0v4zP2O6VBVMG3RHm7W5Yo1Dz0r9kL/ho= xorm.io/builder v0.3.7 h1:2pETdKRK+2QG4mLX4oODHEhn5Z8j1m8sXa7jfu+/SZI= xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= +xorm.io/builder v0.3.8 h1:P/wPgRqa9kX5uE0aA1/ukJ23u9KH0aSRpHLwDKXigSE= +xorm.io/builder v0.3.8/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/core v0.7.3 h1:W8ws1PlrnkS1CZU1YWaYLMQcQilwAmQXU0BJDJon+H0= xorm.io/core v0.7.3/go.mod h1:jJfd0UAEzZ4t87nbQYtVjmqpIODugN6PD2D9E+dJvdM= xorm.io/xorm v1.0.1 h1:/lITxpJtkZauNpdzj+L9CN/3OQxZaABrbergMcJu+Cw= xorm.io/xorm v1.0.1/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY= -xorm.io/xorm v1.0.2 h1:kZlCh9rqd1AzGwWitcrEEqHE1h1eaZE/ujU5/2tWEtg= -xorm.io/xorm v1.0.2/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY= xorm.io/xorm v1.0.5 h1:LRr5PfOUb4ODPR63YwbowkNDwcolT2LnkwP/TUaMaB0= xorm.io/xorm v1.0.5/go.mod h1:uF9EtbhODq5kNWxMbnBEj8hRRZnlcNSz2t2N7HW/+A4= +xorm.io/xorm v1.0.7 h1:26yBTDVI+CfQpVz2Y88fISh+aiJXIPP4eNoTJlwzsC4= +xorm.io/xorm v1.0.7/go.mod h1:uF9EtbhODq5kNWxMbnBEj8hRRZnlcNSz2t2N7HW/+A4= diff --git a/magefile.go b/magefile.go index 575102584..31176ad2a 100644 --- a/magefile.go +++ b/magefile.go @@ -24,6 +24,7 @@ import ( "context" "crypto/sha256" "fmt" + "github.com/iancoleman/strcase" "io" "io/ioutil" "os" @@ -60,13 +61,15 @@ var ( // Aliases are mage aliases of targets Aliases = map[string]interface{}{ - "build": Build.Build, - "do-the-swag": DoTheSwag, - "check:got-swag": Check.GotSwag, - "release:os-package": Release.OsPackage, - "dev:create-migration": Dev.CreateMigration, - "generate-docs": GenerateDocs, - "check:golangci-fix": Check.GolangciFix, + "build": Build.Build, + "do-the-swag": DoTheSwag, + "check:got-swag": Check.GotSwag, + "release:os-package": Release.OsPackage, + "dev:make-migration": Dev.MakeMigration, + "dev:make-event": Dev.MakeEvent, + "dev:make-listener": Dev.MakeListener, + "generate-docs": GenerateDocs, + "check:golangci-fix": Check.GolangciFix, } ) @@ -294,6 +297,25 @@ func moveFile(src, dst string) error { return nil } +func appendToFile(filename, content string) error { + f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return err + } + + defer f.Close() + + _, err = f.WriteString(content) + return err +} + +const InfoColor = "\033[1;32m%s\033[0m" + +func printSuccess(text string, args ...interface{}) { + text = fmt.Sprintf(text, args...) + fmt.Printf(InfoColor+"\n", text) +} + // Formats the code using go fmt func Fmt() { mg.Deps(initVars) @@ -695,7 +717,7 @@ func (Release) Packages() error { type Dev mg.Namespace // Creates a new bare db migration skeleton in pkg/migration with the current date -func (Dev) CreateMigration() error { +func (Dev) MakeMigration() error { reader := bufio.NewReader(os.Stdin) fmt.Print("Enter the name of the struct: ") @@ -747,14 +769,129 @@ func init() { }) } ` - f, err := os.Create(RootPath + "/pkg/migration/" + date + ".go") + filename := "/pkg/migration/" + date + ".go" + f, err := os.Create(RootPath + filename) defer f.Close() if err != nil { return err } - _, err = f.WriteString(migration) - return err + if _, err := f.WriteString(migration); err != nil { + return err + } + + printSuccess("Migration has been created at %s!", filename) + + return nil +} + +// Create a new event. Takes the name of the event as the first argument and the module where the event should be created as the second argument. Events will be appended to the pkg//events.go file. +func (Dev) MakeEvent(name, module string) error { + + name = strcase.ToCamel(name) + + if !strings.HasSuffix(name, "Event") { + name += "Event" + } + + eventName := strings.ReplaceAll(strcase.ToDelimited(name, '.'), ".event", "") + + newEventCode := ` +// ` + name + ` represents a ` + name + ` event +type ` + name + ` struct { +} + +// Name defines the name for ` + name + ` +func (t *` + name + `) Name() string { + return "` + eventName + `" +} +` + filename := "./pkg/" + module + "/events.go" + if err := appendToFile(filename, newEventCode); err != nil { + return err + } + + printSuccess("The new event has been created successfully! Head over to %s and adjust its content.", filename) + + return nil +} + +// Create a new listener for an event. Takes the name of the listener, the name of the event to listen to and the module where everything should be placed as parameters. +func (Dev) MakeListener(name, event, module string) error { + name = strcase.ToCamel(name) + listenerName := strcase.ToDelimited(name, '.') + listenerCode := ` +// ` + name + ` represents a listener +type ` + name + ` struct { +} + +// Name defines the name for the ` + name + ` listener +func (s *` + name + `) Name() string { + return "` + listenerName + `" +} + +// Hanlde is executed when the event ` + name + ` listens on is fired +func (s *` + name + `) Handle(payload message.Payload) (err error) { + event := &` + event + `{} + err = json.Unmarshal(payload, event) + if err != nil { + return err + } + + return nil +} +` + filename := "./pkg/" + module + "/listeners.go" + + ////// + // Register the listener + + file, err := os.Open(filename) + if err != nil { + return err + } + + scanner := bufio.NewScanner(file) + var idx int64 = 0 + for scanner.Scan() { + if scanner.Text() == "}" { + //idx -= int64(len(scanner.Text())) + break + } + idx += int64(len(scanner.Bytes()) + 1) + } + file.Close() + + registerListenerCode := ` events.RegisterListener((&` + event + `{}).Name(), &` + name + `{}) +` + + f, err := os.OpenFile(filename, os.O_RDWR, 0600) + if err != nil { + return err + } + + defer f.Close() + + if _, err := f.Seek(idx, 0); err != nil { + return err + } + remainder, err := ioutil.ReadAll(f) + if err != nil { + return err + } + f.Seek(idx, 0) + f.Write([]byte(registerListenerCode)) + f.Write(remainder) + + /////// + // Append the listener code + if err := appendToFile(filename, listenerCode); err != nil { + return err + } + + printSuccess("The new listener has been created successfully! Head over to %s and adjust its content.", filename) + + return nil } type configOption struct { diff --git a/pkg/config/config.go b/pkg/config/config.go index 48a2ba6e3..c837c164e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -101,6 +101,8 @@ const ( LogHTTP Key = `log.http` LogEcho Key = `log.echo` LogPath Key = `log.path` + LogEvents Key = `log.events` + LogEventsLevel Key = `log.eventslevel` RateLimitEnabled Key = `ratelimit.enabled` RateLimitKind Key = `ratelimit.kind` @@ -281,6 +283,8 @@ func InitDefaultConfig() { LogHTTP.setDefault("stdout") LogEcho.setDefault("off") LogPath.setDefault(ServiceRootpath.GetString() + "/logs") + LogEvents.setDefault("stdout") + LogEventsLevel.setDefault("INFO") // Rate Limit RateLimitEnabled.setDefault(false) RateLimitKind.setDefault("user") diff --git a/pkg/events/events.go b/pkg/events/events.go new file mode 100644 index 000000000..72fbfd8c9 --- /dev/null +++ b/pkg/events/events.go @@ -0,0 +1,97 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package events + +import ( + "context" + "encoding/json" + "time" + + "code.vikunja.io/api/pkg/log" + vmetrics "code.vikunja.io/api/pkg/metrics" + "github.com/ThreeDotsLabs/watermill" + "github.com/ThreeDotsLabs/watermill/components/metrics" + "github.com/ThreeDotsLabs/watermill/message" + "github.com/ThreeDotsLabs/watermill/message/router/middleware" + "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" +) + +var pubsub *gochannel.GoChannel + +// Event represents the event interface used by all events +type Event interface { + Name() string +} + +// InitEvents sets up everything needed to work with events +func InitEvents() (err error) { + logger := log.NewWatermillLogger() + + router, err := message.NewRouter( + message.RouterConfig{}, + logger, + ) + if err != nil { + return err + } + + router.AddMiddleware( + middleware.Retry{ + MaxRetries: 5, + InitialInterval: time.Millisecond * 100, + Logger: logger, + Multiplier: 2, + }.Middleware, + middleware.Recoverer, + ) + + metricsBuilder := metrics.NewPrometheusMetricsBuilder(vmetrics.GetRegistry(), "", "") + metricsBuilder.AddPrometheusRouterMetrics(router) + + pubsub = gochannel.NewGoChannel( + gochannel.Config{ + OutputChannelBuffer: 1024, + }, + logger, + ) + + for topic, funcs := range listeners { + for _, handler := range funcs { + router.AddNoPublisherHandler(topic+"."+handler.Name(), topic, pubsub, func(msg *message.Message) error { + return handler.Handle(msg.Payload) + }) + } + } + + return router.Run(context.Background()) +} + +// Dispatch dispatches an event +func Dispatch(event Event) error { + if isUnderTest { + dispatchedTestEvents = append(dispatchedTestEvents, event) + return nil + } + + content, err := json.Marshal(event) + if err != nil { + return err + } + + msg := message.NewMessage(watermill.NewUUID(), content) + return pubsub.Publish(event.Name(), msg) +} diff --git a/pkg/events/listeners.go b/pkg/events/listeners.go new file mode 100644 index 000000000..f0b1f4266 --- /dev/null +++ b/pkg/events/listeners.go @@ -0,0 +1,36 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package events + +import "github.com/ThreeDotsLabs/watermill/message" + +// Listener represents something that listens to events +type Listener interface { + Handle(payload message.Payload) error + Name() string +} + +var listeners map[string][]Listener + +func init() { + listeners = make(map[string][]Listener) +} + +// RegisterListener is used to register a listener when a specific event happens +func RegisterListener(name string, listener Listener) { + listeners[name] = append(listeners[name], listener) +} diff --git a/pkg/events/testing.go b/pkg/events/testing.go new file mode 100644 index 000000000..bf105e110 --- /dev/null +++ b/pkg/events/testing.go @@ -0,0 +1,49 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package events + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + isUnderTest bool + dispatchedTestEvents []Event +) + +// Fake sets up the "test mode" of the events package. Typically you'd call this function in the TestMain function +// in the package you're testing. It will prevent any events from being fired, instead they will be recorded and be +// available for assertions. +func Fake() { + isUnderTest = true + dispatchedTestEvents = nil +} + +// AssertDispatched asserts an event has been dispatched. +func AssertDispatched(t *testing.T, event Event) { + var found bool + for _, testEvent := range dispatchedTestEvents { + if event.Name() == testEvent.Name() { + found = true + break + } + } + + assert.True(t, found, "Failed to assert "+event.Name()+" has been dispatched.") +} diff --git a/pkg/initialize/events.go b/pkg/initialize/events.go new file mode 100644 index 000000000..c5c52a34c --- /dev/null +++ b/pkg/initialize/events.go @@ -0,0 +1,29 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package initialize + +import "time" + +// BootedEvent represents a BootedEvent event +type BootedEvent struct { + BootedAt time.Time +} + +// TopicName defines the name for BootedEvent +func (t *BootedEvent) Name() string { + return "booted" +} diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index 57b9bba19..b817de434 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -17,8 +17,11 @@ package initialize import ( + "time" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/cron" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/mail" @@ -85,4 +88,21 @@ func FullInit() { // Start the cron cron.Init() models.RegisterReminderCron() + + // Start processing events + go func() { + models.RegisterListeners() + user.RegisterListeners() + err := events.InitEvents() + if err != nil { + log.Fatal(err.Error()) + } + + err = events.Dispatch(&BootedEvent{ + BootedAt: time.Now(), + }) + if err != nil { + log.Fatal(err) + } + }() } diff --git a/pkg/integrations/integrations.go b/pkg/integrations/integrations.go index 11c2e1152..ad4f4fbc4 100644 --- a/pkg/integrations/integrations.go +++ b/pkg/integrations/integrations.go @@ -24,6 +24,8 @@ import ( "strings" "testing" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/files" @@ -85,6 +87,7 @@ func setupTestEnv() (e *echo.Echo, err error) { files.InitTests() user.InitTests() models.SetupTests() + events.Fake() err = db.LoadFixtures() if err != nil { diff --git a/pkg/log/logging.go b/pkg/log/logging.go index 34621e438..3fb4701ba 100644 --- a/pkg/log/logging.go +++ b/pkg/log/logging.go @@ -49,6 +49,7 @@ func InitLogger() { config.LogDatabase.Set("off") config.LogHTTP.Set("off") config.LogEcho.Set("off") + config.LogEvents.Set("off") return } diff --git a/pkg/log/watermill_logger.go b/pkg/log/watermill_logger.go new file mode 100644 index 000000000..4a6c4e418 --- /dev/null +++ b/pkg/log/watermill_logger.go @@ -0,0 +1,91 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package log + +import ( + "fmt" + "strings" + "time" + + "code.vikunja.io/api/pkg/config" + "github.com/ThreeDotsLabs/watermill" + "github.com/op/go-logging" +) + +const watermillFmt = `%{color}%{time:` + time.RFC3339Nano + `}: %{level}` + "\t" + `▶ [EVENTS] %{id:03x}%{color:reset} %{message}` + +const watermillLogModule = `vikunja_events` + +type WatermillLogger struct { + logger *logging.Logger +} + +func NewWatermillLogger() *WatermillLogger { + lvl := strings.ToUpper(config.LogEventsLevel.GetString()) + level, err := logging.LogLevel(lvl) + if err != nil { + Criticalf("Error setting events log level %s: %s", lvl, err.Error()) + } + + watermillLogger := &WatermillLogger{ + logger: logging.MustGetLogger(watermillLogModule), + } + + logBackend := logging.NewLogBackend(GetLogWriter("events"), "", 0) + backend := logging.NewBackendFormatter(logBackend, logging.MustStringFormatter(watermillFmt+"\n")) + + backendLeveled := logging.AddModuleLevel(backend) + backendLeveled.SetLevel(level, watermillLogModule) + + watermillLogger.logger.SetBackend(backendLeveled) + + return watermillLogger +} + +func concatFields(fields watermill.LogFields) string { + full := "" + + for key, val := range fields { + full += fmt.Sprintf("%s=%s, ", key, val) + } + + if full != "" { + full = full[:len(full)-2] + } + + return full +} + +func (w *WatermillLogger) Error(msg string, err error, fields watermill.LogFields) { + w.logger.Errorf("%s: %s, %s", msg, err, concatFields(fields)) +} + +func (w *WatermillLogger) Info(msg string, fields watermill.LogFields) { + w.logger.Infof("%s, %s", msg, concatFields(fields)) +} + +func (w *WatermillLogger) Debug(msg string, fields watermill.LogFields) { + w.logger.Debugf("%s, %s", msg, concatFields(fields)) +} + +func (w *WatermillLogger) Trace(msg string, fields watermill.LogFields) { + w.logger.Debugf("%s, %s", msg, concatFields(fields)) +} + +func (w *WatermillLogger) With(fields watermill.LogFields) watermill.LoggerAdapter { + return w +} diff --git a/pkg/log/xorm_logger.go b/pkg/log/xorm_logger.go index 2d8a65f6d..1e2fe9867 100644 --- a/pkg/log/xorm_logger.go +++ b/pkg/log/xorm_logger.go @@ -44,7 +44,7 @@ func NewXormLogger(lvl string) *XormLogger { } level, err := logging.LogLevel(lvl) if err != nil { - Critical("Error setting database log level: %s", err.Error()) + Criticalf("Error setting database log level: %s", err.Error()) } xormLogger := &XormLogger{ diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 1922843b2..aa79edede 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -17,7 +17,6 @@ package metrics import ( - "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/modules/keyvalue" "github.com/prometheus/client_golang/prometheus" @@ -41,6 +40,18 @@ const ( TeamCountKey = `teamcount` ) +var registry *prometheus.Registry + +func GetRegistry() *prometheus.Registry { + if registry == nil { + registry = prometheus.NewRegistry() + registry.MustRegister(prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{})) + registry.MustRegister(prometheus.NewGoCollector()) + } + + return registry +} + // InitMetrics Initializes the metrics func InitMetrics() { // init active users, sometimes we'll have garbage from previous runs in redis instead @@ -48,50 +59,67 @@ func InitMetrics() { log.Fatalf("Could not set initial count for active users, error was %s", err) } + GetRegistry() + // Register total list count metric - promauto.NewGaugeFunc(prometheus.GaugeOpts{ + err := registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{ Name: "vikunja_list_count", Help: "The number of lists on this instance", }, func() float64 { count, _ := GetCount(ListCountKey) return float64(count) - }) + })) + if err != nil { + log.Criticalf("Could not register metrics for %s: %s", ListCountKey, err) + } // Register total user count metric - promauto.NewGaugeFunc(prometheus.GaugeOpts{ + err = registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{ Name: "vikunja_user_count", Help: "The total number of users on this instance", }, func() float64 { count, _ := GetCount(UserCountKey) return float64(count) - }) + })) + if err != nil { + log.Criticalf("Could not register metrics for %s: %s", UserCountKey, err) + } // Register total Namespaces count metric - promauto.NewGaugeFunc(prometheus.GaugeOpts{ - Name: "vikunja_namespcae_count", + err = registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "vikunja_namespace_count", Help: "The total number of namespaces on this instance", }, func() float64 { count, _ := GetCount(NamespaceCountKey) return float64(count) - }) + })) + if err != nil { + log.Criticalf("Could not register metrics for %s: %s", NamespaceCountKey, err) + } // Register total Tasks count metric - promauto.NewGaugeFunc(prometheus.GaugeOpts{ + err = registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{ Name: "vikunja_task_count", Help: "The total number of tasks on this instance", }, func() float64 { count, _ := GetCount(TaskCountKey) return float64(count) - }) + })) + if err != nil { + log.Criticalf("Could not register metrics for %s: %s", TaskCountKey, err) + } // Register total user count metric - promauto.NewGaugeFunc(prometheus.GaugeOpts{ + err = registry.Register(promauto.NewGaugeFunc(prometheus.GaugeOpts{ Name: "vikunja_team_count", Help: "The total number of teams on this instance", }, func() float64 { count, _ := GetCount(TeamCountKey) return float64(count) - }) + })) + if err != nil { + log.Criticalf("Could not register metrics for %s: %s", TeamCountKey, err) + } } // GetCount returns the current count from redis @@ -113,22 +141,3 @@ func GetCount(key string) (count int64, err error) { func SetCount(count int64, key string) error { return keyvalue.Put(key, count) } - -// UpdateCount updates a count with a given amount -func UpdateCount(update int64, key string) { - if !config.ServiceEnableMetrics.GetBool() { - return - } - if update > 0 { - err := keyvalue.IncrBy(key, update) - if err != nil { - log.Error(err.Error()) - } - } - if update < 0 { - err := keyvalue.DecrBy(key, update) - if err != nil { - log.Error(err.Error()) - } - } -} diff --git a/pkg/models/bulk_task.go b/pkg/models/bulk_task.go index 5b5e21c1b..a4d3ee531 100644 --- a/pkg/models/bulk_task.go +++ b/pkg/models/bulk_task.go @@ -78,14 +78,14 @@ func (bt *BulkTask) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { // @Failure 403 {object} web.HTTPError "The user does not have access to the task (aka its list)" // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/bulk [post] -func (bt *BulkTask) Update(s *xorm.Session) (err error) { +func (bt *BulkTask) Update(s *xorm.Session, a web.Auth) (err error) { for _, oldtask := range bt.Tasks { // When a repeating task is marked as done, we update all deadlines and reminders and set it as undone updateDone(oldtask, &bt.Task) // Update the assignees - if err := oldtask.updateTaskAssignees(s, bt.Assignees); err != nil { + if err := oldtask.updateTaskAssignees(s, bt.Assignees, a); err != nil { return err } diff --git a/pkg/models/bulk_task_test.go b/pkg/models/bulk_task_test.go index 60101a68b..7159302fc 100644 --- a/pkg/models/bulk_task_test.go +++ b/pkg/models/bulk_task_test.go @@ -84,7 +84,7 @@ func TestBulkTask_Update(t *testing.T) { if !allowed != tt.wantForbidden { t.Errorf("BulkTask.Update() want forbidden, got %v, want %v", allowed, tt.wantForbidden) } - if err := bt.Update(s); (err != nil) != tt.wantErr { + if err := bt.Update(s, tt.fields.User); (err != nil) != tt.wantErr { t.Errorf("BulkTask.Update() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/pkg/models/events.go b/pkg/models/events.go new file mode 100644 index 000000000..8b37fa0de --- /dev/null +++ b/pkg/models/events.go @@ -0,0 +1,247 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/web" +) + +///////////////// +// Task Events // +///////////////// + +// TaskCreatedEvent represents an event where a task has been created +type TaskCreatedEvent struct { + Task *Task + Doer web.Auth +} + +// Name defines the name for TaskCreatedEvent +func (t *TaskCreatedEvent) Name() string { + return "task.created" +} + +// TaskUpdatedEvent represents an event where a task has been updated +type TaskUpdatedEvent struct { + Task *Task + Doer web.Auth +} + +// Name defines the name for TaskUpdatedEvent +func (t *TaskUpdatedEvent) Name() string { + return "task.updated" +} + +// TaskDeletedEvent represents a TaskDeletedEvent event +type TaskDeletedEvent struct { + Task *Task + Doer web.Auth +} + +// Name defines the name for TaskDeletedEvent +func (t *TaskDeletedEvent) Name() string { + return "task.deleted" +} + +// TaskAssigneeCreatedEvent represents an event where a task has been assigned to a user +type TaskAssigneeCreatedEvent struct { + Task *Task + Assignee *user.User + Doer web.Auth +} + +// Name defines the name for TaskAssigneeCreatedEvent +func (t *TaskAssigneeCreatedEvent) Name() string { + return "task.assignee.created" +} + +// TaskCommentCreatedEvent represents an event where a task comment has been created +type TaskCommentCreatedEvent struct { + Task *Task + Comment *TaskComment + Doer web.Auth +} + +// Name defines the name for TaskCommentCreatedEvent +func (t *TaskCommentCreatedEvent) Name() string { + return "task.comment.created" +} + +////////////////////// +// Namespace Events // +////////////////////// + +// NamespaceCreatedEvent represents an event where a namespace has been created +type NamespaceCreatedEvent struct { + Namespace *Namespace + Doer web.Auth +} + +// Name defines the name for NamespaceCreatedEvent +func (n *NamespaceCreatedEvent) Name() string { + return "namespace.created" +} + +// NamespaceUpdatedEvent represents an event where a namespace has been updated +type NamespaceUpdatedEvent struct { + Namespace *Namespace + Doer web.Auth +} + +// Name defines the name for NamespaceUpdatedEvent +func (n *NamespaceUpdatedEvent) Name() string { + return "namespace.updated" +} + +// NamespaceDeletedEvent represents a NamespaceDeletedEvent event +type NamespaceDeletedEvent struct { + Namespace *Namespace + Doer web.Auth +} + +// TopicName defines the name for NamespaceDeletedEvent +func (t *NamespaceDeletedEvent) Name() string { + return "namespace.deleted" +} + +///////////////// +// List Events // +///////////////// + +// ListCreatedEvent represents an event where a list has been created +type ListCreatedEvent struct { + List *List + Doer web.Auth +} + +// Name defines the name for ListCreatedEvent +func (l *ListCreatedEvent) Name() string { + return "list.created" +} + +// ListUpdatedEvent represents an event where a list has been updated +type ListUpdatedEvent struct { + List *List + Doer web.Auth +} + +// Name defines the name for ListUpdatedEvent +func (l *ListUpdatedEvent) Name() string { + return "list.updated" +} + +// ListDeletedEvent represents an event where a list has been deleted +type ListDeletedEvent struct { + List *List + Doer web.Auth +} + +// Name defines the name for ListDeletedEvent +func (t *ListDeletedEvent) Name() string { + return "list.deleted" +} + +//////////////////// +// Sharing Events // +//////////////////// + +// ListSharedWithUserEvent represents an event where a list has been shared with a user +type ListSharedWithUserEvent struct { + List *List + User *user.User + Doer web.Auth +} + +// Name defines the name for ListSharedWithUserEvent +func (l *ListSharedWithUserEvent) Name() string { + return "list.shared.user" +} + +// ListSharedWithTeamEvent represents an event where a list has been shared with a team +type ListSharedWithTeamEvent struct { + List *List + Team *Team + Doer web.Auth +} + +// Name defines the name for ListSharedWithTeamEvent +func (l *ListSharedWithTeamEvent) Name() string { + return "list.shared.team" +} + +// NamespaceSharedWithUserEvent represents an event where a namespace has been shared with a user +type NamespaceSharedWithUserEvent struct { + Namespace *Namespace + User *user.User + Doer web.Auth +} + +// Name defines the name for NamespaceSharedWithUserEvent +func (n *NamespaceSharedWithUserEvent) Name() string { + return "namespace.shared.user" +} + +// NamespaceSharedWithTeamEvent represents an event where a namespace has been shared with a team +type NamespaceSharedWithTeamEvent struct { + Namespace *Namespace + Team *Team + Doer web.Auth +} + +// Name defines the name for NamespaceSharedWithTeamEvent +func (n *NamespaceSharedWithTeamEvent) Name() string { + return "namespace.shared.team" +} + +///////////////// +// Team Events // +///////////////// + +// TeamMemberAddedEvent defines an event where a user is added to a team +type TeamMemberAddedEvent struct { + Team *Team + Member *user.User + Doer web.Auth +} + +// Name defines the name for TeamMemberAddedEvent +func (t *TeamMemberAddedEvent) Name() string { + return "team.member.added" +} + +// TeamCreatedEvent represents a TeamCreatedEvent event +type TeamCreatedEvent struct { + Team *Team + Doer web.Auth +} + +// Name defines the name for TeamCreatedEvent +func (t *TeamCreatedEvent) Name() string { + return "team.created" +} + +// TeamDeletedEvent represents a TeamDeletedEvent event +type TeamDeletedEvent struct { + Team *Team + Doer web.Auth +} + +// Name defines the name for TeamDeletedEvent +func (t *TeamDeletedEvent) Name() string { + return "team.deleted" +} diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 5b37f55cd..9b6907d83 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -190,7 +190,7 @@ func (b *Bucket) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 404 {object} web.HTTPError "The bucket does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{listID}/buckets/{bucketID} [post] -func (b *Bucket) Update(s *xorm.Session) (err error) { +func (b *Bucket) Update(s *xorm.Session, a web.Auth) (err error) { _, err = s. Where("id = ?", b.ID). Cols("title", "limit"). @@ -211,7 +211,7 @@ func (b *Bucket) Update(s *xorm.Session) (err error) { // @Failure 404 {object} web.HTTPError "The bucket does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{listID}/buckets/{bucketID} [delete] -func (b *Bucket) Delete(s *xorm.Session) (err error) { +func (b *Bucket) Delete(s *xorm.Session, a web.Auth) (err error) { // Prevent removing the last bucket total, err := s.Where("list_id = ?", b.ListID).Count(&Bucket{}) diff --git a/pkg/models/kanban_test.go b/pkg/models/kanban_test.go index f7fd0c29e..a3add2c4f 100644 --- a/pkg/models/kanban_test.go +++ b/pkg/models/kanban_test.go @@ -92,6 +92,8 @@ func TestBucket_ReadAll(t *testing.T) { } func TestBucket_Delete(t *testing.T) { + user := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -101,7 +103,7 @@ func TestBucket_Delete(t *testing.T) { ID: 2, // The second bucket only has 3 tasks ListID: 1, } - err := b.Delete(s) + err := b.Delete(s, user) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -125,7 +127,7 @@ func TestBucket_Delete(t *testing.T) { ID: 34, ListID: 18, } - err := b.Delete(s) + err := b.Delete(s, user) assert.Error(t, err) assert.True(t, IsErrCannotRemoveLastBucket(err)) err = s.Commit() @@ -141,7 +143,7 @@ func TestBucket_Delete(t *testing.T) { func TestBucket_Update(t *testing.T) { testAndAssertBucketUpdate := func(t *testing.T, b *Bucket, s *xorm.Session) { - err := b.Update(s) + err := b.Update(s, &user.User{ID: 1}) assert.NoError(t, err) err = s.Commit() diff --git a/pkg/models/label.go b/pkg/models/label.go index 751a5bf6f..890a7164f 100644 --- a/pkg/models/label.go +++ b/pkg/models/label.go @@ -93,7 +93,7 @@ func (l *Label) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 404 {object} web.HTTPError "Label not found." // @Failure 500 {object} models.Message "Internal error" // @Router /labels/{id} [put] -func (l *Label) Update(s *xorm.Session) (err error) { +func (l *Label) Update(s *xorm.Session, a web.Auth) (err error) { _, err = s. ID(l.ID). Cols( @@ -106,7 +106,7 @@ func (l *Label) Update(s *xorm.Session) (err error) { return } - err = l.ReadOne(s) + err = l.ReadOne(s, a) return } @@ -123,7 +123,7 @@ func (l *Label) Update(s *xorm.Session) (err error) { // @Failure 404 {object} web.HTTPError "Label not found." // @Failure 500 {object} models.Message "Internal error" // @Router /labels/{id} [delete] -func (l *Label) Delete(s *xorm.Session) (err error) { +func (l *Label) Delete(s *xorm.Session, a web.Auth) (err error) { _, err = s.ID(l.ID).Delete(&Label{}) return err } @@ -178,7 +178,7 @@ func (l *Label) ReadAll(s *xorm.Session, a web.Auth, search string, page int, pe // @Failure 404 {object} web.HTTPError "Label not found" // @Failure 500 {object} models.Message "Internal error" // @Router /labels/{id} [get] -func (l *Label) ReadOne(s *xorm.Session) (err error) { +func (l *Label) ReadOne(s *xorm.Session, a web.Auth) (err error) { label, err := getLabelByIDSimple(s, l.ID) if err != nil { return err diff --git a/pkg/models/label_task.go b/pkg/models/label_task.go index 04c289579..de5be5c1f 100644 --- a/pkg/models/label_task.go +++ b/pkg/models/label_task.go @@ -61,7 +61,7 @@ func (LabelTask) TableName() string { // @Failure 404 {object} web.HTTPError "Label not found." // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{task}/labels/{label} [delete] -func (lt *LabelTask) Delete(s *xorm.Session) (err error) { +func (lt *LabelTask) Delete(s *xorm.Session, a web.Auth) (err error) { _, err = s.Delete(&LabelTask{LabelID: lt.LabelID, TaskID: lt.TaskID}) return err } @@ -208,6 +208,10 @@ func getLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*lab return nil, 0, 0, err } + if len(labels) == 0 { + return nil, 0, 0, nil + } + // Get all created by users var userids []int64 for _, l := range labels { diff --git a/pkg/models/label_task_test.go b/pkg/models/label_task_test.go index 663eda426..9005a125a 100644 --- a/pkg/models/label_task_test.go +++ b/pkg/models/label_task_test.go @@ -318,7 +318,7 @@ func TestLabelTask_Delete(t *testing.T) { if !allowed && !tt.wantForbidden { t.Errorf("LabelTask.CanDelete() forbidden, want %v", tt.wantForbidden) } - err := l.Delete(s) + err := l.Delete(s, tt.auth) if (err != nil) != tt.wantErr { t.Errorf("LabelTask.Delete() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/pkg/models/label_test.go b/pkg/models/label_test.go index 8db37e240..54949700f 100644 --- a/pkg/models/label_test.go +++ b/pkg/models/label_test.go @@ -257,7 +257,7 @@ func TestLabel_ReadOne(t *testing.T) { if !allowed && !tt.wantForbidden { t.Errorf("Label.CanRead() forbidden, want %v", tt.wantForbidden) } - err := l.ReadOne(s) + err := l.ReadOne(s, tt.auth) if (err != nil) != tt.wantErr { t.Errorf("Label.ReadOne() error = %v, wantErr %v", err, tt.wantErr) } @@ -419,7 +419,7 @@ func TestLabel_Update(t *testing.T) { if !allowed && !tt.wantForbidden { t.Errorf("Label.CanUpdate() forbidden, want %v", tt.wantForbidden) } - if err := l.Update(s); (err != nil) != tt.wantErr { + if err := l.Update(s, tt.auth); (err != nil) != tt.wantErr { t.Errorf("Label.Update() error = %v, wantErr %v", err, tt.wantErr) } if !tt.wantErr && !tt.wantForbidden { @@ -505,7 +505,7 @@ func TestLabel_Delete(t *testing.T) { if !allowed && !tt.wantForbidden { t.Errorf("Label.CanDelete() forbidden, want %v", tt.wantForbidden) } - if err := l.Delete(s); (err != nil) != tt.wantErr { + if err := l.Delete(s, tt.auth); (err != nil) != tt.wantErr { t.Errorf("Label.Delete() error = %v, wantErr %v", err, tt.wantErr) } if !tt.wantErr && !tt.wantForbidden { diff --git a/pkg/models/link_sharing.go b/pkg/models/link_sharing.go index 972bab498..363897abe 100644 --- a/pkg/models/link_sharing.go +++ b/pkg/models/link_sharing.go @@ -127,7 +127,7 @@ func (share *LinkSharing) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 404 {object} web.HTTPError "Share Link not found." // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{list}/shares/{share} [get] -func (share *LinkSharing) ReadOne(s *xorm.Session) (err error) { +func (share *LinkSharing) ReadOne(s *xorm.Session, a web.Auth) (err error) { exists, err := s.Where("id = ?", share.ID).Get(share) if err != nil { return err @@ -216,7 +216,7 @@ func (share *LinkSharing) ReadAll(s *xorm.Session, a web.Auth, search string, pa // @Failure 404 {object} web.HTTPError "Share Link not found." // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{list}/shares/{share} [delete] -func (share *LinkSharing) Delete(s *xorm.Session) (err error) { +func (share *LinkSharing) Delete(s *xorm.Session, a web.Auth) (err error) { _, err = s.Where("id = ?", share.ID).Delete(share) return } diff --git a/pkg/models/list.go b/pkg/models/list.go index dfe841bc4..6d65af10d 100644 --- a/pkg/models/list.go +++ b/pkg/models/list.go @@ -21,10 +21,11 @@ import ( "strings" "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/files" - "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "xorm.io/builder" @@ -186,7 +187,7 @@ func (l *List) ReadAll(s *xorm.Session, a web.Auth, search string, page int, per // @Failure 403 {object} web.HTTPError "The user does not have access to the list" // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{id} [get] -func (l *List) ReadOne(s *xorm.Session) (err error) { +func (l *List) ReadOne(s *xorm.Session, a web.Auth) (err error) { if l.ID == FavoritesPseudoList.ID { // Already "built" the list in CanRead @@ -388,6 +389,10 @@ func getRawListsForUser(s *xorm.Session, opts *listOptions) (lists []*List, resu // addListDetails adds owner user objects and list tasks to all lists in the slice func addListDetails(s *xorm.Session, lists []*List) (err error) { + if len(lists) == 0 { + return + } + var ownerIDs []int64 for _, l := range lists { ownerIDs = append(ownerIDs, l.OwnerID) @@ -411,6 +416,10 @@ func addListDetails(s *xorm.Session, lists []*List) (err error) { fileIDs = append(fileIDs, l.BackgroundFileID) } + if len(fileIDs) == 0 { + return + } + // Unsplash background file info us := []*UnsplashPhoto{} err = s.In("file_id", fileIDs).Find(&us) @@ -466,7 +475,7 @@ func (l *List) CheckIsArchived(s *xorm.Session) (err error) { } // CreateOrUpdateList updates a list or creates it if it doesn't exist -func CreateOrUpdateList(s *xorm.Session, list *List) (err error) { +func CreateOrUpdateList(s *xorm.Session, list *List, auth web.Auth) (err error) { // Check if the namespace exists if list.NamespaceID != 0 && list.NamespaceID != FavoritesPseudoNamespace.ID { @@ -492,7 +501,6 @@ func CreateOrUpdateList(s *xorm.Session, list *List) (err error) { if list.ID == 0 { _, err = s.Insert(list) - metrics.UpdateCount(1, metrics.ListCountKey) } else { // We need to specify the cols we want to update here to be able to un-archive lists colsToUpdate := []string{ @@ -522,7 +530,7 @@ func CreateOrUpdateList(s *xorm.Session, list *List) (err error) { } *list = *l - err = list.ReadOne(s) + err = list.ReadOne(s, auth) return } @@ -541,8 +549,16 @@ func CreateOrUpdateList(s *xorm.Session, list *List) (err error) { // @Failure 403 {object} web.HTTPError "The user does not have access to the list" // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{id} [post] -func (l *List) Update(s *xorm.Session) (err error) { - return CreateOrUpdateList(s, l) +func (l *List) Update(s *xorm.Session, a web.Auth) (err error) { + err = CreateOrUpdateList(s, l, a) + if err != nil { + return err + } + + return events.Dispatch(&ListUpdatedEvent{ + List: l, + Doer: a, + }) } func updateListLastUpdated(s *xorm.Session, list *List) error { @@ -589,7 +605,7 @@ func (l *List) Create(s *xorm.Session, a web.Auth) (err error) { l.Owner = doer l.ID = 0 // Otherwise only the first time a new list would be created - err = CreateOrUpdateList(s, l) + err = CreateOrUpdateList(s, l, a) if err != nil { return } @@ -599,7 +615,15 @@ func (l *List) Create(s *xorm.Session, a web.Auth) (err error) { ListID: l.ID, Title: "New Bucket", } - return b.Create(s, a) + err = b.Create(s, a) + if err != nil { + return + } + + return events.Dispatch(&ListCreatedEvent{ + List: l, + Doer: a, + }) } // Delete implements the delete method of CRUDable @@ -614,18 +638,24 @@ func (l *List) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 403 {object} web.HTTPError "The user does not have access to the list" // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{id} [delete] -func (l *List) Delete(s *xorm.Session) (err error) { +func (l *List) Delete(s *xorm.Session, a web.Auth) (err error) { // Delete the list _, err = s.ID(l.ID).Delete(&List{}) if err != nil { return } - metrics.UpdateCount(-1, metrics.ListCountKey) - // Delete all todotasks on that list + // Delete all tasks on that list _, err = s.Where("list_id = ?", l.ID).Delete(&Task{}) - return + if err != nil { + return + } + + return events.Dispatch(&ListDeletedEvent{ + List: l, + Doer: a, + }) } // SetListBackground sets a background file as list background in the db diff --git a/pkg/models/list_duplicate.go b/pkg/models/list_duplicate.go index edfd239ae..827d4b3e2 100644 --- a/pkg/models/list_duplicate.go +++ b/pkg/models/list_duplicate.go @@ -67,15 +67,15 @@ func (ld *ListDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bool, // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{listID}/duplicate [put] //nolint:gocyclo -func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) { +func (ld *ListDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { log.Debugf("Duplicating list %d", ld.ListID) ld.List.ID = 0 ld.List.Identifier = "" // Reset the identifier to trigger regenerating a new one // Set the owner to the current user - ld.List.OwnerID = a.GetID() - if err := CreateOrUpdateList(s, ld.List); err != nil { + ld.List.OwnerID = doer.GetID() + if err := CreateOrUpdateList(s, ld.List, doer); err != nil { // If there is no available unique list identifier, just reset it. if IsErrListIdentifierIsNotUnique(err) { ld.List.Identifier = "" @@ -99,7 +99,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) { oldID := b.ID b.ID = 0 b.ListID = ld.List.ID - if err := b.Create(s, a); err != nil { + if err := b.Create(s, doer); err != nil { return err } bucketMap[oldID] = b.ID @@ -108,7 +108,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) { log.Debugf("Duplicated all buckets from list %d into %d", ld.ListID, ld.List.ID) // Get all tasks + all task details - tasks, _, _, err := getTasksForLists(s, []*List{{ID: ld.ListID}}, a, &taskOptions{}) + tasks, _, _, err := getTasksForLists(s, []*List{{ID: ld.ListID}}, doer, &taskOptions{}) if err != nil { return err } @@ -124,7 +124,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) { t.ListID = ld.List.ID t.BucketID = bucketMap[t.BucketID] t.UID = "" - err := createTask(s, t, a, false) + err := createTask(s, t, doer, false) if err != nil { return err } @@ -163,7 +163,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) { return err } - err := attachment.NewAttachment(s, attachment.File.File, attachment.File.Name, attachment.File.Size, a) + err := attachment.NewAttachment(s, attachment.File.File, attachment.File.Name, attachment.File.Size, doer) if err != nil { return err } @@ -206,7 +206,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) { ID: taskMap[a.TaskID], ListID: ld.List.ID, } - if err := t.addNewAssigneeByID(s, a.UserID, ld.List); err != nil { + if err := t.addNewAssigneeByID(s, a.UserID, ld.List, doer); err != nil { if IsErrUserDoesNotHaveAccessToList(err) { continue } @@ -269,7 +269,7 @@ func (ld *ListDuplicate) Create(s *xorm.Session, a web.Auth) (err error) { } defer f.File.Close() - file, err := files.Create(f.File, f.Name, f.Size, a) + file, err := files.Create(f.File, f.Name, f.Size, doer) if err != nil { return err } diff --git a/pkg/models/list_team.go b/pkg/models/list_team.go index cbdedcf11..3362d7d1d 100644 --- a/pkg/models/list_team.go +++ b/pkg/models/list_team.go @@ -19,6 +19,8 @@ package models import ( "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/web" "xorm.io/xorm" ) @@ -77,9 +79,9 @@ func (tl *TeamList) Create(s *xorm.Session, a web.Auth) (err error) { } // Check if the team exists - _, err = GetTeamByID(s, tl.TeamID) + team, err := GetTeamByID(s, tl.TeamID) if err != nil { - return + return err } // Check if the list exists @@ -105,6 +107,15 @@ func (tl *TeamList) Create(s *xorm.Session, a web.Auth) (err error) { return err } + err = events.Dispatch(&ListSharedWithTeamEvent{ + List: l, + Team: team, + Doer: a, + }) + if err != nil { + return err + } + err = updateListLastUpdated(s, l) return } @@ -122,7 +133,7 @@ func (tl *TeamList) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 404 {object} web.HTTPError "Team or list does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{listID}/teams/{teamID} [delete] -func (tl *TeamList) Delete(s *xorm.Session) (err error) { +func (tl *TeamList) Delete(s *xorm.Session, a web.Auth) (err error) { // Check if the team exists _, err = GetTeamByID(s, tl.TeamID) @@ -234,7 +245,7 @@ func (tl *TeamList) ReadAll(s *xorm.Session, a web.Auth, search string, page int // @Failure 404 {object} web.HTTPError "Team or list does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{listID}/teams/{teamID} [post] -func (tl *TeamList) Update(s *xorm.Session) (err error) { +func (tl *TeamList) Update(s *xorm.Session, a web.Auth) (err error) { // Check if the right is valid if err := tl.Right.isValid(); err != nil { diff --git a/pkg/models/list_team_test.go b/pkg/models/list_team_test.go index 3bc251d02..31c48a561 100644 --- a/pkg/models/list_team_test.go +++ b/pkg/models/list_team_test.go @@ -158,6 +158,8 @@ func TestTeamList_Create(t *testing.T) { } func TestTeamList_Delete(t *testing.T) { + user := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -165,7 +167,7 @@ func TestTeamList_Delete(t *testing.T) { TeamID: 1, ListID: 3, } - err := tl.Delete(s) + err := tl.Delete(s, user) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -181,7 +183,7 @@ func TestTeamList_Delete(t *testing.T) { TeamID: 9999, ListID: 1, } - err := tl.Delete(s) + err := tl.Delete(s, user) assert.Error(t, err) assert.True(t, IsErrTeamDoesNotExist(err)) _ = s.Close() @@ -193,7 +195,7 @@ func TestTeamList_Delete(t *testing.T) { TeamID: 1, ListID: 9999, } - err := tl.Delete(s) + err := tl.Delete(s, user) assert.Error(t, err) assert.True(t, IsErrTeamDoesNotHaveAccessToList(err)) _ = s.Close() @@ -267,7 +269,7 @@ func TestTeamList_Update(t *testing.T) { CRUDable: tt.fields.CRUDable, Rights: tt.fields.Rights, } - err := tl.Update(s) + err := tl.Update(s, &user.User{ID: 1}) if (err != nil) != tt.wantErr { t.Errorf("TeamList.Update() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/pkg/models/list_test.go b/pkg/models/list_test.go index 6154c134e..933484a7d 100644 --- a/pkg/models/list_test.go +++ b/pkg/models/list_test.go @@ -125,7 +125,7 @@ func TestList_CreateOrUpdate(t *testing.T) { NamespaceID: 1, } list.Description = "Lorem Ipsum dolor sit amet." - err := list.Update(s) + err := list.Update(s, usr) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -143,7 +143,7 @@ func TestList_CreateOrUpdate(t *testing.T) { ID: 99999999, Title: "test", } - err := list.Update(s) + err := list.Update(s, usr) assert.Error(t, err) assert.True(t, IsErrListDoesNotExist(err)) _ = s.Close() @@ -172,7 +172,7 @@ func TestList_Delete(t *testing.T) { list := List{ ID: 1, } - err := list.Delete(s) + err := list.Delete(s, &user.User{ID: 1}) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) diff --git a/pkg/models/list_users.go b/pkg/models/list_users.go index 5ca3735be..06909eb6e 100644 --- a/pkg/models/list_users.go +++ b/pkg/models/list_users.go @@ -19,6 +19,8 @@ package models import ( "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "xorm.io/xorm" @@ -112,6 +114,15 @@ func (lu *ListUser) Create(s *xorm.Session, a web.Auth) (err error) { return err } + err = events.Dispatch(&ListSharedWithUserEvent{ + List: l, + User: u, + Doer: a, + }) + if err != nil { + return err + } + err = updateListLastUpdated(s, l) return } @@ -129,7 +140,7 @@ func (lu *ListUser) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 404 {object} web.HTTPError "user or list does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{listID}/users/{userID} [delete] -func (lu *ListUser) Delete(s *xorm.Session) (err error) { +func (lu *ListUser) Delete(s *xorm.Session, a web.Auth) (err error) { // Check if the user exists u, err := user.GetUserByUsername(s, lu.Username) @@ -231,7 +242,7 @@ func (lu *ListUser) ReadAll(s *xorm.Session, a web.Auth, search string, page int // @Failure 404 {object} web.HTTPError "User or list does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{listID}/users/{userID} [post] -func (lu *ListUser) Update(s *xorm.Session) (err error) { +func (lu *ListUser) Update(s *xorm.Session, a web.Auth) (err error) { // Check if the right is valid if err := lu.Right.isValid(); err != nil { diff --git a/pkg/models/list_users_test.go b/pkg/models/list_users_test.go index 3565d2b30..9825f4b55 100644 --- a/pkg/models/list_users_test.go +++ b/pkg/models/list_users_test.go @@ -311,7 +311,7 @@ func TestListUser_Update(t *testing.T) { CRUDable: tt.fields.CRUDable, Rights: tt.fields.Rights, } - err := lu.Update(s) + err := lu.Update(s, &user.User{ID: 1}) if (err != nil) != tt.wantErr { t.Errorf("ListUser.Update() error = %v, wantErr %v", err, tt.wantErr) } @@ -393,7 +393,7 @@ func TestListUser_Delete(t *testing.T) { CRUDable: tt.fields.CRUDable, Rights: tt.fields.Rights, } - err := lu.Delete(s) + err := lu.Delete(s, &user.User{ID: 1}) if (err != nil) != tt.wantErr { t.Errorf("ListUser.Delete() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go new file mode 100644 index 000000000..74826f76a --- /dev/null +++ b/pkg/models/listeners.go @@ -0,0 +1,154 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/metrics" + "code.vikunja.io/api/pkg/modules/keyvalue" + "github.com/ThreeDotsLabs/watermill/message" +) + +// RegisterListeners registers all event listeners +func RegisterListeners() { + events.RegisterListener((&ListCreatedEvent{}).Name(), &IncreaseListCounter{}) + events.RegisterListener((&ListDeletedEvent{}).Name(), &DecreaseListCounter{}) + events.RegisterListener((&NamespaceCreatedEvent{}).Name(), &IncreaseNamespaceCounter{}) + events.RegisterListener((&NamespaceDeletedEvent{}).Name(), &DecreaseNamespaceCounter{}) + events.RegisterListener((&TaskCreatedEvent{}).Name(), &IncreaseTaskCounter{}) + events.RegisterListener((&TaskDeletedEvent{}).Name(), &DecreaseTaskCounter{}) + events.RegisterListener((&TeamDeletedEvent{}).Name(), &DecreaseTeamCounter{}) + events.RegisterListener((&TeamCreatedEvent{}).Name(), &IncreaseTeamCounter{}) +} + +////// +// Task Events + +// IncreaseTaskCounter represents a listener +type IncreaseTaskCounter struct { +} + +// Name defines the name for the IncreaseTaskCounter listener +func (s *IncreaseTaskCounter) Name() string { + return "task.counter.increase" +} + +// Hanlde is executed when the event IncreaseTaskCounter listens on is fired +func (s *IncreaseTaskCounter) Handle(payload message.Payload) (err error) { + return keyvalue.IncrBy(metrics.TaskCountKey, 1) +} + +// DecreaseTaskCounter represents a listener +type DecreaseTaskCounter struct { +} + +// Name defines the name for the DecreaseTaskCounter listener +func (s *DecreaseTaskCounter) Name() string { + return "task.counter.decrease" +} + +// Hanlde is executed when the event DecreaseTaskCounter listens on is fired +func (s *DecreaseTaskCounter) Handle(payload message.Payload) (err error) { + return keyvalue.DecrBy(metrics.TaskCountKey, 1) +} + +/////// +// List Event Listeners + +type IncreaseListCounter struct { +} + +func (s *IncreaseListCounter) Name() string { + return "list.counter.increase" +} + +func (s *IncreaseListCounter) Handle(payload message.Payload) (err error) { + return keyvalue.IncrBy(metrics.ListCountKey, 1) +} + +type DecreaseListCounter struct { +} + +func (s *DecreaseListCounter) Name() string { + return "list.counter.decrease" +} + +func (s *DecreaseListCounter) Handle(payload message.Payload) (err error) { + return keyvalue.DecrBy(metrics.ListCountKey, 1) +} + +////// +// Namespace events + +// IncreaseNamespaceCounter represents a listener +type IncreaseNamespaceCounter struct { +} + +// Name defines the name for the IncreaseNamespaceCounter listener +func (s *IncreaseNamespaceCounter) Name() string { + return "namespace.counter.increase" +} + +// Hanlde is executed when the event IncreaseNamespaceCounter listens on is fired +func (s *IncreaseNamespaceCounter) Handle(payload message.Payload) (err error) { + return keyvalue.IncrBy(metrics.NamespaceCountKey, 1) +} + +// DecreaseNamespaceCounter represents a listener +type DecreaseNamespaceCounter struct { +} + +// Name defines the name for the DecreaseNamespaceCounter listener +func (s *DecreaseNamespaceCounter) Name() string { + return "namespace.counter.decrease" +} + +// Hanlde is executed when the event DecreaseNamespaceCounter listens on is fired +func (s *DecreaseNamespaceCounter) Handle(payload message.Payload) (err error) { + return keyvalue.DecrBy(metrics.NamespaceCountKey, 1) +} + +/////// +// Team Events + +// IncreaseTeamCounter represents a listener +type IncreaseTeamCounter struct { +} + +// Name defines the name for the IncreaseTeamCounter listener +func (s *IncreaseTeamCounter) Name() string { + return "team.counter.increase" +} + +// Hanlde is executed when the event IncreaseTeamCounter listens on is fired +func (s *IncreaseTeamCounter) Handle(payload message.Payload) (err error) { + return keyvalue.IncrBy(metrics.TeamCountKey, 1) +} + +// DecreaseTeamCounter represents a listener +type DecreaseTeamCounter struct { +} + +// Name defines the name for the DecreaseTeamCounter listener +func (s *DecreaseTeamCounter) Name() string { + return "team.counter.decrease" +} + +// Hanlde is executed when the event DecreaseTeamCounter listens on is fired +func (s *DecreaseTeamCounter) Handle(payload message.Payload) (err error) { + return keyvalue.DecrBy(metrics.TeamCountKey, 1) +} diff --git a/pkg/models/main_test.go b/pkg/models/main_test.go index e52ceda10..70795c44b 100644 --- a/pkg/models/main_test.go +++ b/pkg/models/main_test.go @@ -22,6 +22,8 @@ import ( "testing" "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/user" @@ -64,5 +66,7 @@ func TestMain(m *testing.M) { SetupTests() + events.Fake() + os.Exit(m.Run()) } diff --git a/pkg/models/namespace.go b/pkg/models/namespace.go index 591983ee8..7f4f00c36 100644 --- a/pkg/models/namespace.go +++ b/pkg/models/namespace.go @@ -22,8 +22,9 @@ import ( "strings" "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "xorm.io/builder" @@ -159,7 +160,7 @@ func (n *Namespace) CheckIsArchived(s *xorm.Session) error { // @Failure 403 {object} web.HTTPError "The user does not have access to that namespace." // @Failure 500 {object} models.Message "Internal error" // @Router /namespaces/{id} [get] -func (n *Namespace) ReadOne(s *xorm.Session) (err error) { +func (n *Namespace) ReadOne(s *xorm.Session, a web.Auth) (err error) { nn, err := GetNamespaceByID(s, n.ID) if err != nil { return err @@ -478,7 +479,14 @@ func (n *Namespace) Create(s *xorm.Session, a web.Auth) (err error) { return err } - metrics.UpdateCount(1, metrics.NamespaceCountKey) + err = events.Dispatch(&NamespaceCreatedEvent{ + Namespace: n, + Doer: a, + }) + if err != nil { + return err + } + return } @@ -504,7 +512,7 @@ func CreateNewNamespaceForUser(s *xorm.Session, user *user.User) (err error) { // @Failure 403 {object} web.HTTPError "The user does not have access to the namespace" // @Failure 500 {object} models.Message "Internal error" // @Router /namespaces/{id} [delete] -func (n *Namespace) Delete(s *xorm.Session) (err error) { +func (n *Namespace) Delete(s *xorm.Session, a web.Auth) (err error) { // Check if the namespace exists _, err = GetNamespaceByID(s, n.ID) @@ -523,6 +531,14 @@ func (n *Namespace) Delete(s *xorm.Session) (err error) { if err != nil { return } + + if len(lists) == 0 { + return events.Dispatch(&NamespaceDeletedEvent{ + Namespace: n, + Doer: a, + }) + } + var listIDs []int64 // We need to do that for here because we need the list ids to delete two times: // 1) to delete the lists itself @@ -543,9 +559,10 @@ func (n *Namespace) Delete(s *xorm.Session) (err error) { return } - metrics.UpdateCount(-1, metrics.NamespaceCountKey) - - return + return events.Dispatch(&NamespaceDeletedEvent{ + Namespace: n, + Doer: a, + }) } // Update implements the update method via the interface @@ -562,7 +579,7 @@ func (n *Namespace) Delete(s *xorm.Session) (err error) { // @Failure 403 {object} web.HTTPError "The user does not have access to the namespace" // @Failure 500 {object} models.Message "Internal error" // @Router /namespace/{id} [post] -func (n *Namespace) Update(s *xorm.Session) (err error) { +func (n *Namespace) Update(s *xorm.Session, a web.Auth) (err error) { // Check if we have at least a name if n.Title == "" { return ErrNamespaceNameCannotBeEmpty{NamespaceID: n.ID} @@ -605,5 +622,12 @@ func (n *Namespace) Update(s *xorm.Session) (err error) { ID(currentNamespace.ID). Cols(colsToUpdate...). Update(n) - return + if err != nil { + return err + } + + return events.Dispatch(&NamespaceUpdatedEvent{ + Namespace: n, + Doer: a, + }) } diff --git a/pkg/models/namespace_team.go b/pkg/models/namespace_team.go index 27add095f..956eb0cb1 100644 --- a/pkg/models/namespace_team.go +++ b/pkg/models/namespace_team.go @@ -19,6 +19,8 @@ package models import ( "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/web" "xorm.io/xorm" ) @@ -71,15 +73,15 @@ func (tn *TeamNamespace) Create(s *xorm.Session, a web.Auth) (err error) { } // Check if the team exists - _, err = GetTeamByID(s, tn.TeamID) + team, err := GetTeamByID(s, tn.TeamID) if err != nil { - return + return err } // Check if the namespace exists - _, err = GetNamespaceByID(s, tn.NamespaceID) + namespace, err := GetNamespaceByID(s, tn.NamespaceID) if err != nil { - return + return err } // Check if the team already has access to the namespace @@ -96,7 +98,15 @@ func (tn *TeamNamespace) Create(s *xorm.Session, a web.Auth) (err error) { // Insert the new team _, err = s.Insert(tn) - return + if err != nil { + return err + } + + return events.Dispatch(&NamespaceSharedWithTeamEvent{ + Namespace: namespace, + Team: team, + Doer: a, + }) } // Delete deletes a team <-> namespace relation based on the namespace & team id @@ -112,7 +122,7 @@ func (tn *TeamNamespace) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 404 {object} web.HTTPError "team or namespace does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /namespaces/{namespaceID}/teams/{teamID} [delete] -func (tn *TeamNamespace) Delete(s *xorm.Session) (err error) { +func (tn *TeamNamespace) Delete(s *xorm.Session, a web.Auth) (err error) { // Check if the team exists _, err = GetTeamByID(s, tn.TeamID) @@ -219,7 +229,7 @@ func (tn *TeamNamespace) ReadAll(s *xorm.Session, a web.Auth, search string, pag // @Failure 404 {object} web.HTTPError "Team or namespace does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /namespaces/{namespaceID}/teams/{teamID} [post] -func (tn *TeamNamespace) Update(s *xorm.Session) (err error) { +func (tn *TeamNamespace) Update(s *xorm.Session, a web.Auth) (err error) { // Check if the right is valid if err := tn.Right.isValid(); err != nil { diff --git a/pkg/models/namespace_team_test.go b/pkg/models/namespace_team_test.go index c8f7dad02..86b393387 100644 --- a/pkg/models/namespace_team_test.go +++ b/pkg/models/namespace_team_test.go @@ -157,7 +157,7 @@ func TestTeamNamespace_Delete(t *testing.T) { s := db.NewSession() allowed, _ := tn.CanDelete(s, u) assert.True(t, allowed) - err := tn.Delete(s) + err := tn.Delete(s, u) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -174,7 +174,7 @@ func TestTeamNamespace_Delete(t *testing.T) { } db.LoadAndAssertFixtures(t) s := db.NewSession() - err := tn.Delete(s) + err := tn.Delete(s, u) assert.Error(t, err) assert.True(t, IsErrTeamDoesNotExist(err)) _ = s.Close() @@ -186,7 +186,7 @@ func TestTeamNamespace_Delete(t *testing.T) { } db.LoadAndAssertFixtures(t) s := db.NewSession() - err := tn.Delete(s) + err := tn.Delete(s, u) assert.Error(t, err) assert.True(t, IsErrTeamDoesNotHaveAccessToNamespace(err)) _ = s.Close() @@ -260,7 +260,7 @@ func TestTeamNamespace_Update(t *testing.T) { CRUDable: tt.fields.CRUDable, Rights: tt.fields.Rights, } - err := tl.Update(s) + err := tl.Update(s, &user.User{ID: 1}) if (err != nil) != tt.wantErr { t.Errorf("TeamNamespace.Update() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/pkg/models/namespace_test.go b/pkg/models/namespace_test.go index 09089ba70..f989c7ce7 100644 --- a/pkg/models/namespace_test.go +++ b/pkg/models/namespace_test.go @@ -69,11 +69,13 @@ func TestNamespace_Create(t *testing.T) { } func TestNamespace_ReadOne(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { n := &Namespace{ID: 1} db.LoadAndAssertFixtures(t) s := db.NewSession() - err := n.ReadOne(s) + err := n.ReadOne(s, u) assert.NoError(t, err) assert.Equal(t, n.Title, "testnamespace") _ = s.Close() @@ -82,7 +84,7 @@ func TestNamespace_ReadOne(t *testing.T) { n := &Namespace{ID: 99999} db.LoadAndAssertFixtures(t) s := db.NewSession() - err := n.ReadOne(s) + err := n.ReadOne(s, u) assert.Error(t, err) assert.True(t, IsErrNamespaceDoesNotExist(err)) _ = s.Close() @@ -90,6 +92,8 @@ func TestNamespace_ReadOne(t *testing.T) { } func TestNamespace_Update(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -97,7 +101,7 @@ func TestNamespace_Update(t *testing.T) { ID: 1, Title: "Lorem Ipsum", } - err := n.Update(s) + err := n.Update(s, u) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -114,7 +118,7 @@ func TestNamespace_Update(t *testing.T) { ID: 99999, Title: "Lorem Ipsum", } - err := n.Update(s) + err := n.Update(s, u) assert.Error(t, err) assert.True(t, IsErrNamespaceDoesNotExist(err)) _ = s.Close() @@ -127,7 +131,7 @@ func TestNamespace_Update(t *testing.T) { Title: "Lorem Ipsum", Owner: &user.User{ID: 99999}, } - err := n.Update(s) + err := n.Update(s, u) assert.Error(t, err) assert.True(t, user.IsErrUserDoesNotExist(err)) _ = s.Close() @@ -138,7 +142,7 @@ func TestNamespace_Update(t *testing.T) { n := &Namespace{ ID: 1, } - err := n.Update(s) + err := n.Update(s, u) assert.Error(t, err) assert.True(t, IsErrNamespaceNameCannotBeEmpty(err)) _ = s.Close() @@ -146,13 +150,15 @@ func TestNamespace_Update(t *testing.T) { } func TestNamespace_Delete(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() n := &Namespace{ ID: 1, } - err := n.Delete(s) + err := n.Delete(s, u) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -167,7 +173,7 @@ func TestNamespace_Delete(t *testing.T) { n := &Namespace{ ID: 9999, } - err := n.Delete(s) + err := n.Delete(s, u) assert.Error(t, err) assert.True(t, IsErrNamespaceDoesNotExist(err)) _ = s.Close() diff --git a/pkg/models/namespace_users.go b/pkg/models/namespace_users.go index a54ff02fc..e912695f2 100644 --- a/pkg/models/namespace_users.go +++ b/pkg/models/namespace_users.go @@ -19,6 +19,8 @@ package models import ( "time" + "code.vikunja.io/api/pkg/events" + user2 "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "xorm.io/xorm" @@ -75,7 +77,7 @@ func (nu *NamespaceUser) Create(s *xorm.Session, a web.Auth) (err error) { } // Check if the namespace exists - l, err := GetNamespaceByID(s, nu.NamespaceID) + n, err := GetNamespaceByID(s, nu.NamespaceID) if err != nil { return } @@ -89,7 +91,7 @@ func (nu *NamespaceUser) Create(s *xorm.Session, a web.Auth) (err error) { // Check if the user already has access or is owner of that namespace // We explicitly DO NOT check for teams here - if l.OwnerID == nu.UserID { + if n.OwnerID == nu.UserID { return ErrUserAlreadyHasNamespaceAccess{UserID: nu.UserID, NamespaceID: nu.NamespaceID} } @@ -105,8 +107,15 @@ func (nu *NamespaceUser) Create(s *xorm.Session, a web.Auth) (err error) { // Insert user <-> namespace relation _, err = s.Insert(nu) + if err != nil { + return err + } - return + return events.Dispatch(&NamespaceSharedWithUserEvent{ + Namespace: n, + User: user, + Doer: a, + }) } // Delete deletes a namespace <-> user relation @@ -122,7 +131,7 @@ func (nu *NamespaceUser) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 404 {object} web.HTTPError "user or namespace does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /namespaces/{namespaceID}/users/{userID} [delete] -func (nu *NamespaceUser) Delete(s *xorm.Session) (err error) { +func (nu *NamespaceUser) Delete(s *xorm.Session, a web.Auth) (err error) { // Check if the user exists user, err := user2.GetUserByUsername(s, nu.Username) @@ -220,7 +229,7 @@ func (nu *NamespaceUser) ReadAll(s *xorm.Session, a web.Auth, search string, pag // @Failure 404 {object} web.HTTPError "User or namespace does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /namespaces/{namespaceID}/users/{userID} [post] -func (nu *NamespaceUser) Update(s *xorm.Session) (err error) { +func (nu *NamespaceUser) Update(s *xorm.Session, a web.Auth) (err error) { // Check if the right is valid if err := nu.Right.isValid(); err != nil { diff --git a/pkg/models/namespace_users_test.go b/pkg/models/namespace_users_test.go index 1e1dcd57b..0d557124b 100644 --- a/pkg/models/namespace_users_test.go +++ b/pkg/models/namespace_users_test.go @@ -315,7 +315,7 @@ func TestNamespaceUser_Update(t *testing.T) { CRUDable: tt.fields.CRUDable, Rights: tt.fields.Rights, } - err := nu.Update(s) + err := nu.Update(s, &user.User{ID: 1}) if (err != nil) != tt.wantErr { t.Errorf("NamespaceUser.Update() error = %v, wantErr %v", err, tt.wantErr) } @@ -396,7 +396,7 @@ func TestNamespaceUser_Delete(t *testing.T) { CRUDable: tt.fields.CRUDable, Rights: tt.fields.Rights, } - err := nu.Delete(s) + err := nu.Delete(s, &user.User{ID: 1}) if (err != nil) != tt.wantErr { t.Errorf("NamespaceUser.Delete() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/pkg/models/saved_filters.go b/pkg/models/saved_filters.go index c1d334b26..1d93a5290 100644 --- a/pkg/models/saved_filters.go +++ b/pkg/models/saved_filters.go @@ -133,7 +133,7 @@ func getSavedFilterSimpleByID(s *xorm.Session, id int64) (sf *SavedFilter, err e // @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter." // @Failure 500 {object} models.Message "Internal error" // @Router /filters/{id} [get] -func (sf *SavedFilter) ReadOne(s *xorm.Session) error { +func (sf *SavedFilter) ReadOne(s *xorm.Session, a web.Auth) error { // s already contains almost the full saved filter from the rights check, we only need to add the user u, err := user.GetUserByID(s, sf.OwnerID) sf.Owner = u @@ -153,7 +153,7 @@ func (sf *SavedFilter) ReadOne(s *xorm.Session) error { // @Failure 404 {object} web.HTTPError "The saved filter does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /filters/{id} [post] -func (sf *SavedFilter) Update(s *xorm.Session) error { +func (sf *SavedFilter) Update(s *xorm.Session, a web.Auth) error { _, err := s. Where("id = ?", sf.ID). Cols( @@ -178,7 +178,7 @@ func (sf *SavedFilter) Update(s *xorm.Session) error { // @Failure 404 {object} web.HTTPError "The saved filter does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /filters/{id} [delete] -func (sf *SavedFilter) Delete(s *xorm.Session) error { +func (sf *SavedFilter) Delete(s *xorm.Session, a web.Auth) error { _, err := s. Where("id = ?", sf.ID). Delete(sf) diff --git a/pkg/models/saved_filters_test.go b/pkg/models/saved_filters_test.go index 988ffbcf3..f0256203e 100644 --- a/pkg/models/saved_filters_test.go +++ b/pkg/models/saved_filters_test.go @@ -86,7 +86,7 @@ func TestSavedFilter_ReadOne(t *testing.T) { // canRead pre-populates the struct _, _, err := sf.CanRead(s, user1) assert.NoError(t, err) - err = sf.ReadOne(s) + err = sf.ReadOne(s, user1) assert.NoError(t, err) assert.NotNil(t, sf.Owner) } @@ -102,7 +102,7 @@ func TestSavedFilter_Update(t *testing.T) { Description: "", // Explicitly reset the description Filters: &TaskCollection{}, } - err := sf.Update(s) + err := sf.Update(s, &user.User{ID: 1}) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -121,7 +121,7 @@ func TestSavedFilter_Delete(t *testing.T) { sf := &SavedFilter{ ID: 1, } - err := sf.Delete(s) + err := sf.Delete(s, &user.User{ID: 1}) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) diff --git a/pkg/models/task_assignees.go b/pkg/models/task_assignees.go index f89e54382..d15648322 100644 --- a/pkg/models/task_assignees.go +++ b/pkg/models/task_assignees.go @@ -19,6 +19,8 @@ package models import ( "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "xorm.io/xorm" @@ -57,7 +59,7 @@ func getRawTaskAssigneesForTasks(s *xorm.Session, taskIDs []int64) (taskAssignee } // Create or update a bunch of task assignees -func (t *Task) updateTaskAssignees(s *xorm.Session, assignees []*user.User) (err error) { +func (t *Task) updateTaskAssignees(s *xorm.Session, assignees []*user.User, doer web.Auth) (err error) { // Load the current assignees currentAssignees, err := getRawTaskAssigneesForTasks(s, []int64{t.ID}) @@ -132,7 +134,7 @@ func (t *Task) updateTaskAssignees(s *xorm.Session, assignees []*user.User) (err } // Add the new assignee - err = t.addNewAssigneeByID(s, u.ID, list) + err = t.addNewAssigneeByID(s, u.ID, list, doer) if err != nil { return err } @@ -166,7 +168,7 @@ func (t *Task) setTaskAssignees(assignees []*user.User) { // @Failure 403 {object} web.HTTPError "Not allowed to delete the assignee." // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{taskID}/assignees/{userID} [delete] -func (la *TaskAssginee) Delete(s *xorm.Session) (err error) { +func (la *TaskAssginee) Delete(s *xorm.Session, a web.Auth) (err error) { _, err = s.Delete(&TaskAssginee{TaskID: la.TaskID, UserID: la.UserID}) if err != nil { return err @@ -198,10 +200,10 @@ func (la *TaskAssginee) Create(s *xorm.Session, a web.Auth) (err error) { } task := &Task{ID: la.TaskID} - return task.addNewAssigneeByID(s, la.UserID, list) + return task.addNewAssigneeByID(s, la.UserID, list, a) } -func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, list *List) (err error) { +func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, list *List, auth web.Auth) (err error) { // Check if the user exists and has access to the list newAssignee, err := user.GetUserByID(s, newAssigneeID) if err != nil { @@ -223,6 +225,15 @@ func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, list *Li return err } + err = events.Dispatch(&TaskAssigneeCreatedEvent{ + Task: t, + Assignee: newAssignee, + Doer: auth, + }) + if err != nil { + return err + } + err = updateListLastUpdated(s, &List{ID: t.ListID}) return } @@ -313,6 +324,6 @@ func (ba *BulkAssignees) Create(s *xorm.Session, a web.Auth) (err error) { task.Assignees = append(task.Assignees, &a.User) } - err = task.updateTaskAssignees(s, ba.Assignees) + err = task.updateTaskAssignees(s, ba.Assignees, a) return } diff --git a/pkg/models/task_attachment.go b/pkg/models/task_attachment.go index adf3984b3..6d7e70925 100644 --- a/pkg/models/task_attachment.go +++ b/pkg/models/task_attachment.go @@ -80,7 +80,7 @@ func (ta *TaskAttachment) NewAttachment(s *xorm.Session, f io.ReadCloser, realna } // ReadOne returns a task attachment -func (ta *TaskAttachment) ReadOne(s *xorm.Session) (err error) { +func (ta *TaskAttachment) ReadOne(s *xorm.Session, a web.Auth) (err error) { exists, err := s.Where("id = ?", ta.ID).Get(ta) if err != nil { return @@ -176,9 +176,9 @@ func (ta *TaskAttachment) ReadAll(s *xorm.Session, a web.Auth, search string, pa // @Failure 404 {object} models.Message "The task does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{id}/attachments/{attachmentID} [delete] -func (ta *TaskAttachment) Delete(s *xorm.Session) error { +func (ta *TaskAttachment) Delete(s *xorm.Session, a web.Auth) error { // Load the attachment - err := ta.ReadOne(s) + err := ta.ReadOne(s, a) if err != nil && !files.IsErrFileDoesNotExist(err) { return err } @@ -209,6 +209,10 @@ func getTaskAttachmentsByTaskIDs(s *xorm.Session, taskIDs []int64) (attachments return } + if len(attachments) == 0 { + return + } + fileIDs := []int64{} userIDs := []int64{} for _, a := range attachments { diff --git a/pkg/models/task_attachment_test.go b/pkg/models/task_attachment_test.go index 9a0821123..7d6dbad56 100644 --- a/pkg/models/task_attachment_test.go +++ b/pkg/models/task_attachment_test.go @@ -30,6 +30,8 @@ import ( ) func TestTaskAttachment_ReadOne(t *testing.T) { + u := &user.User{ID: 1} + t.Run("Normal File", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -39,7 +41,7 @@ func TestTaskAttachment_ReadOne(t *testing.T) { ta := &TaskAttachment{ ID: 1, } - err := ta.ReadOne(s) + err := ta.ReadOne(s, u) assert.NoError(t, err) assert.NotNil(t, ta.File) assert.True(t, ta.File.ID == ta.FileID && ta.FileID != 0) @@ -63,7 +65,7 @@ func TestTaskAttachment_ReadOne(t *testing.T) { ta := &TaskAttachment{ ID: 9999, } - err := ta.ReadOne(s) + err := ta.ReadOne(s, u) assert.Error(t, err) assert.True(t, IsErrTaskAttachmentDoesNotExist(err)) }) @@ -76,7 +78,7 @@ func TestTaskAttachment_ReadOne(t *testing.T) { ta := &TaskAttachment{ ID: 2, } - err := ta.ReadOne(s) + err := ta.ReadOne(s, u) assert.Error(t, err) assert.EqualError(t, err, "file 9999 does not exist") }) @@ -153,10 +155,12 @@ func TestTaskAttachment_Delete(t *testing.T) { s := db.NewSession() defer s.Close() + u := &user.User{ID: 1} + files.InitTestFileFixtures(t) t.Run("Normal", func(t *testing.T) { ta := &TaskAttachment{ID: 1} - err := ta.Delete(s) + err := ta.Delete(s, u) assert.NoError(t, err) // Check if the file itself was deleted _, err = files.FileStat("/1") // The new file has the id 2 since it's the second attachment @@ -165,14 +169,14 @@ func TestTaskAttachment_Delete(t *testing.T) { t.Run("Nonexisting", func(t *testing.T) { files.InitTestFileFixtures(t) ta := &TaskAttachment{ID: 9999} - err := ta.Delete(s) + err := ta.Delete(s, u) assert.Error(t, err) assert.True(t, IsErrTaskAttachmentDoesNotExist(err)) }) t.Run("Existing attachment, nonexisting file", func(t *testing.T) { files.InitTestFileFixtures(t) ta := &TaskAttachment{ID: 2} - err := ta.Delete(s) + err := ta.Delete(s, u) assert.NoError(t, err) }) } diff --git a/pkg/models/task_comments.go b/pkg/models/task_comments.go index f1e89f261..9bad08c7c 100644 --- a/pkg/models/task_comments.go +++ b/pkg/models/task_comments.go @@ -19,6 +19,8 @@ package models import ( "time" + "code.vikunja.io/api/pkg/events" + "xorm.io/xorm" "code.vikunja.io/api/pkg/user" @@ -60,7 +62,7 @@ func (tc *TaskComment) TableName() string { // @Router /tasks/{taskID}/comments [put] func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) { // Check if the task exists - _, err = GetTaskSimple(s, &Task{ID: tc.TaskID}) + task, err := GetTaskSimple(s, &Task{ID: tc.TaskID}) if err != nil { return err } @@ -70,6 +72,16 @@ func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) { if err != nil { return } + + err = events.Dispatch(&TaskCommentCreatedEvent{ + Task: &task, + Comment: tc, + Doer: a, + }) + if err != nil { + return err + } + tc.Author, err = user.GetUserByID(s, a.GetID()) return } @@ -88,7 +100,7 @@ func (tc *TaskComment) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 404 {object} web.HTTPError "The task comment was not found." // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{taskID}/comments/{commentID} [delete] -func (tc *TaskComment) Delete(s *xorm.Session) error { +func (tc *TaskComment) Delete(s *xorm.Session, a web.Auth) error { deleted, err := s. ID(tc.ID). NoAutoCondition(). @@ -113,7 +125,7 @@ func (tc *TaskComment) Delete(s *xorm.Session) error { // @Failure 404 {object} web.HTTPError "The task comment was not found." // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{taskID}/comments/{commentID} [post] -func (tc *TaskComment) Update(s *xorm.Session) error { +func (tc *TaskComment) Update(s *xorm.Session, a web.Auth) error { updated, err := s. ID(tc.ID). Cols("comment"). @@ -138,7 +150,7 @@ func (tc *TaskComment) Update(s *xorm.Session) error { // @Failure 404 {object} web.HTTPError "The task comment was not found." // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{taskID}/comments/{commentID} [get] -func (tc *TaskComment) ReadOne(s *xorm.Session) (err error) { +func (tc *TaskComment) ReadOne(s *xorm.Session, a web.Auth) (err error) { exists, err := s.Get(tc) if err != nil { return diff --git a/pkg/models/task_comments_test.go b/pkg/models/task_comments_test.go index 95c03e476..3ad3abf8d 100644 --- a/pkg/models/task_comments_test.go +++ b/pkg/models/task_comments_test.go @@ -65,13 +65,15 @@ func TestTaskComment_Create(t *testing.T) { } func TestTaskComment_Delete(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() tc := &TaskComment{ID: 1} - err := tc.Delete(s) + err := tc.Delete(s, u) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -86,13 +88,15 @@ func TestTaskComment_Delete(t *testing.T) { defer s.Close() tc := &TaskComment{ID: 9999} - err := tc.Delete(s) + err := tc.Delete(s, u) assert.Error(t, err) assert.True(t, IsErrTaskCommentDoesNotExist(err)) }) } func TestTaskComment_Update(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -102,7 +106,7 @@ func TestTaskComment_Update(t *testing.T) { ID: 1, Comment: "testing", } - err := tc.Update(s) + err := tc.Update(s, u) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -120,20 +124,22 @@ func TestTaskComment_Update(t *testing.T) { tc := &TaskComment{ ID: 9999, } - err := tc.Update(s) + err := tc.Update(s, u) assert.Error(t, err) assert.True(t, IsErrTaskCommentDoesNotExist(err)) }) } func TestTaskComment_ReadOne(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() tc := &TaskComment{ID: 1} - err := tc.ReadOne(s) + err := tc.ReadOne(s, u) assert.NoError(t, err) assert.Equal(t, "Lorem Ipsum Dolor Sit Amet", tc.Comment) assert.NotEmpty(t, tc.Author.ID) @@ -144,7 +150,7 @@ func TestTaskComment_ReadOne(t *testing.T) { defer s.Close() tc := &TaskComment{ID: 9999} - err := tc.ReadOne(s) + err := tc.ReadOne(s, u) assert.Error(t, err) assert.True(t, IsErrTaskCommentDoesNotExist(err)) }) diff --git a/pkg/models/task_relation.go b/pkg/models/task_relation.go index 85329536e..75dbfc604 100644 --- a/pkg/models/task_relation.go +++ b/pkg/models/task_relation.go @@ -201,7 +201,7 @@ func (rel *TaskRelation) Create(s *xorm.Session, a web.Auth) error { // @Failure 404 {object} web.HTTPError "The task relation was not found." // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{taskID}/relations [delete] -func (rel *TaskRelation) Delete(s *xorm.Session) error { +func (rel *TaskRelation) Delete(s *xorm.Session, a web.Auth) error { // Check if the relation exists exists, err := s. Cols("task_id", "other_task_id", "relation_kind"). diff --git a/pkg/models/task_relation_test.go b/pkg/models/task_relation_test.go index f19149676..219d56ff4 100644 --- a/pkg/models/task_relation_test.go +++ b/pkg/models/task_relation_test.go @@ -97,6 +97,8 @@ func TestTaskRelation_Create(t *testing.T) { } func TestTaskRelation_Delete(t *testing.T) { + u := &user.User{ID: 1} + t.Run("Normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -107,7 +109,7 @@ func TestTaskRelation_Delete(t *testing.T) { OtherTaskID: 29, RelationKind: RelationKindSubtask, } - err := rel.Delete(s) + err := rel.Delete(s, u) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -127,7 +129,7 @@ func TestTaskRelation_Delete(t *testing.T) { OtherTaskID: 3, RelationKind: RelationKindSubtask, } - err := rel.Delete(s) + err := rel.Delete(s, u) assert.Error(t, err) assert.True(t, IsErrRelationDoesNotExist(err)) }) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 0d75990f7..3ee2edc65 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -22,10 +22,11 @@ import ( "strconv" "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" "code.vikunja.io/web" @@ -596,6 +597,11 @@ func addRelatedTasksToTasks(s *xorm.Session, taskIDs []int64, taskMap map[int64] for _, rt := range relatedTasks { relatedTaskIDs = append(relatedTaskIDs, rt.OtherTaskID) } + + if len(relatedTaskIDs) == 0 { + return + } + fullRelatedTasks := make(map[int64]*Task) err = s.In("id", relatedTaskIDs).Find(&fullRelatedTasks) if err != nil { @@ -814,7 +820,7 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err // Update the assignees if updateAssignees { - if err := t.updateTaskAssignees(s, t.Assignees); err != nil { + if err := t.updateTaskAssignees(s, t.Assignees, a); err != nil { return err } } @@ -824,10 +830,16 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err return err } - metrics.UpdateCount(1, metrics.TaskCountKey) - t.setIdentifier(l) + err = events.Dispatch(&TaskCreatedEvent{ + Task: t, + Doer: a, + }) + if err != nil { + return err + } + err = updateListLastUpdated(s, &List{ID: t.ListID}) return } @@ -847,7 +859,7 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{id} [post] //nolint:gocyclo -func (t *Task) Update(s *xorm.Session) (err error) { +func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { // Check if the task exists and get the old values ot, err := GetTaskByIDSimple(s, t.ID) @@ -870,7 +882,7 @@ func (t *Task) Update(s *xorm.Session) (err error) { updateDone(&ot, t) // Update the assignees - if err := ot.updateTaskAssignees(s, t.Assignees); err != nil { + if err := ot.updateTaskAssignees(s, t.Assignees, a); err != nil { return err } @@ -1028,6 +1040,14 @@ func (t *Task) Update(s *xorm.Session) (err error) { } t.Updated = nt.Updated + err = events.Dispatch(&TaskUpdatedEvent{ + Task: t, + Doer: a, + }) + if err != nil { + return err + } + return updateListLastUpdated(s, &List{ID: t.ListID}) } @@ -1166,7 +1186,7 @@ func (t *Task) updateReminders(s *xorm.Session, reminders []time.Time) (err erro // @Failure 403 {object} web.HTTPError "The user does not have access to the list" // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{id} [delete] -func (t *Task) Delete(s *xorm.Session) (err error) { +func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) { if _, err = s.ID(t.ID).Delete(Task{}); err != nil { return err @@ -1177,7 +1197,13 @@ func (t *Task) Delete(s *xorm.Session) (err error) { return err } - metrics.UpdateCount(-1, metrics.TaskCountKey) + err = events.Dispatch(&TaskDeletedEvent{ + Task: t, + Doer: a, + }) + if err != nil { + return + } err = updateListLastUpdated(s, &List{ID: t.ListID}) return @@ -1195,7 +1221,7 @@ func (t *Task) Delete(s *xorm.Session) (err error) { // @Failure 404 {object} models.Message "Task not found" // @Failure 500 {object} models.Message "Internal error" // @Router /tasks/{ID} [get] -func (t *Task) ReadOne(s *xorm.Session) (err error) { +func (t *Task) ReadOne(s *xorm.Session, a web.Auth) (err error) { taskMap := make(map[int64]*Task, 1) taskMap[t.ID] = &Task{} diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index c07e9414f..6c8ae2dfe 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -20,6 +20,8 @@ import ( "testing" "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/user" "github.com/stretchr/testify/assert" @@ -65,6 +67,7 @@ func TestTask_Create(t *testing.T) { "bucket_id": 1, }, false) + events.AssertDispatched(t, &TaskCreatedEvent{}) }) t.Run("empty title", func(t *testing.T) { db.LoadAndAssertFixtures(t) @@ -127,6 +130,8 @@ func TestTask_Create(t *testing.T) { } func TestTask_Update(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -138,7 +143,7 @@ func TestTask_Update(t *testing.T) { Description: "Lorem Ipsum Dolor", ListID: 1, } - err := task.Update(s) + err := task.Update(s, u) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -161,7 +166,7 @@ func TestTask_Update(t *testing.T) { Description: "Lorem Ipsum Dolor", ListID: 1, } - err := task.Update(s) + err := task.Update(s, u) assert.Error(t, err) assert.True(t, IsErrTaskDoesNotExist(err)) }) @@ -177,7 +182,7 @@ func TestTask_Update(t *testing.T) { ListID: 1, BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3 } - err := task.Update(s) + err := task.Update(s, u) assert.Error(t, err) assert.True(t, IsErrBucketLimitExceeded(err)) }) @@ -194,7 +199,7 @@ func TestTask_Update(t *testing.T) { ListID: 1, BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3 } - err := task.Update(s) + err := task.Update(s, u) assert.NoError(t, err) }) } @@ -208,7 +213,7 @@ func TestTask_Delete(t *testing.T) { task := &Task{ ID: 1, } - err := task.Delete(s) + err := task.Delete(s, &user.User{ID: 1}) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -440,13 +445,15 @@ func TestUpdateDone(t *testing.T) { } func TestTask_ReadOne(t *testing.T) { + u := &user.User{ID: 1} + t.Run("default", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() task := &Task{ID: 1} - err := task.ReadOne(s) + err := task.ReadOne(s, u) assert.NoError(t, err) assert.Equal(t, "task #1", task.Title) }) @@ -456,7 +463,7 @@ func TestTask_ReadOne(t *testing.T) { defer s.Close() task := &Task{ID: 99999} - err := task.ReadOne(s) + err := task.ReadOne(s, u) assert.Error(t, err) assert.True(t, IsErrTaskDoesNotExist(err)) }) diff --git a/pkg/models/team_members.go b/pkg/models/team_members.go index c1e0d00b7..d6d64094f 100644 --- a/pkg/models/team_members.go +++ b/pkg/models/team_members.go @@ -17,6 +17,7 @@ package models import ( + "code.vikunja.io/api/pkg/events" user2 "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "xorm.io/xorm" @@ -39,9 +40,9 @@ import ( func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) { // Check if the team extst - _, err = GetTeamByID(s, tm.TeamID) + team, err := GetTeamByID(s, tm.TeamID) if err != nil { - return + return err } // Check if the user exists @@ -64,7 +65,15 @@ func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) { // Insert the user _, err = s.Insert(tm) - return + if err != nil { + return err + } + + return events.Dispatch(&TeamMemberAddedEvent{ + Team: team, + Member: user, + Doer: a, + }) } // Delete deletes a user from a team @@ -78,7 +87,7 @@ func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) { // @Success 200 {object} models.Message "The user was successfully removed from the team." // @Failure 500 {object} models.Message "Internal error" // @Router /teams/{id}/members/{userID} [delete] -func (tm *TeamMember) Delete(s *xorm.Session) (err error) { +func (tm *TeamMember) Delete(s *xorm.Session, a web.Auth) (err error) { total, err := s.Where("team_id = ?", tm.TeamID).Count(&TeamMember{}) if err != nil { @@ -110,7 +119,7 @@ func (tm *TeamMember) Delete(s *xorm.Session) (err error) { // @Success 200 {object} models.Message "The member right was successfully changed." // @Failure 500 {object} models.Message "Internal error" // @Router /teams/{id}/members/{userID}/admin [post] -func (tm *TeamMember) Update(s *xorm.Session) (err error) { +func (tm *TeamMember) Update(s *xorm.Session, a web.Auth) (err error) { // Find the numeric user id user, err := user2.GetUserByUsername(s, tm.Username) if err != nil { diff --git a/pkg/models/team_members_test.go b/pkg/models/team_members_test.go index 8164dd615..c01581511 100644 --- a/pkg/models/team_members_test.go +++ b/pkg/models/team_members_test.go @@ -101,7 +101,7 @@ func TestTeamMember_Delete(t *testing.T) { TeamID: 1, Username: "user1", } - err := tm.Delete(s) + err := tm.Delete(s, &user.User{ID: 1}) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -114,6 +114,8 @@ func TestTeamMember_Delete(t *testing.T) { } func TestTeamMember_Update(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -124,7 +126,7 @@ func TestTeamMember_Update(t *testing.T) { Username: "user1", Admin: true, } - err := tm.Update(s) + err := tm.Update(s, u) assert.NoError(t, err) assert.False(t, tm.Admin) // Since this endpoint toggles the right, we should get a false for admin back. err = s.Commit() @@ -148,7 +150,7 @@ func TestTeamMember_Update(t *testing.T) { Username: "user1", Admin: true, } - err := tm.Update(s) + err := tm.Update(s, u) assert.NoError(t, err) assert.False(t, tm.Admin) err = s.Commit() diff --git a/pkg/models/teams.go b/pkg/models/teams.go index 37781860b..e0727721b 100644 --- a/pkg/models/teams.go +++ b/pkg/models/teams.go @@ -19,9 +19,10 @@ package models import ( "time" + "code.vikunja.io/api/pkg/events" + "xorm.io/xorm" - "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "xorm.io/builder" @@ -119,6 +120,11 @@ func GetTeamByID(s *xorm.Session, id int64) (team *Team, err error) { } func addMoreInfoToTeams(s *xorm.Session, teams []*Team) (err error) { + + if len(teams) == 0 { + return nil + } + // Put the teams in a map to make assigning more info to it more efficient teamMap := make(map[int64]*Team, len(teams)) var teamIDs []int64 @@ -177,7 +183,7 @@ func addMoreInfoToTeams(s *xorm.Session, teams []*Team) (err error) { // @Failure 403 {object} web.HTTPError "The user does not have access to the team" // @Failure 500 {object} models.Message "Internal error" // @Router /teams/{id} [get] -func (t *Team) ReadOne(s *xorm.Session) (err error) { +func (t *Team) ReadOne(s *xorm.Session, a web.Auth) (err error) { team, err := GetTeamByID(s, t.ID) if team != nil { *t = *team @@ -270,8 +276,10 @@ func (t *Team) Create(s *xorm.Session, a web.Auth) (err error) { return err } - metrics.UpdateCount(1, metrics.TeamCountKey) - return + return events.Dispatch(&TeamCreatedEvent{ + Team: t, + Doer: a, + }) } // Delete deletes a team @@ -285,7 +293,7 @@ func (t *Team) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 400 {object} web.HTTPError "Invalid team object provided." // @Failure 500 {object} models.Message "Internal error" // @Router /teams/{id} [delete] -func (t *Team) Delete(s *xorm.Session) (err error) { +func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) { // Delete the team _, err = s.ID(t.ID).Delete(&Team{}) @@ -311,8 +319,10 @@ func (t *Team) Delete(s *xorm.Session) (err error) { return } - metrics.UpdateCount(-1, metrics.TeamCountKey) - return + return events.Dispatch(&TeamDeletedEvent{ + Team: t, + Doer: a, + }) } // Update is the handler to create a team @@ -328,7 +338,7 @@ func (t *Team) Delete(s *xorm.Session) (err error) { // @Failure 400 {object} web.HTTPError "Invalid team object provided." // @Failure 500 {object} models.Message "Internal error" // @Router /teams/{id} [post] -func (t *Team) Update(s *xorm.Session) (err error) { +func (t *Team) Update(s *xorm.Session, a web.Auth) (err error) { // Check if we have a name if t.Name == "" { return ErrTeamNameCannotBeEmpty{} diff --git a/pkg/models/teams_test.go b/pkg/models/teams_test.go index dcd932511..de25b63e9 100644 --- a/pkg/models/teams_test.go +++ b/pkg/models/teams_test.go @@ -62,13 +62,15 @@ func TestTeam_Create(t *testing.T) { } func TestTeam_ReadOne(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() team := &Team{ID: 1} - err := team.ReadOne(s) + err := team.ReadOne(s, u) assert.NoError(t, err) assert.Equal(t, "testteam1", team.Name) assert.Equal(t, "Lorem Ipsum", team.Description) @@ -81,7 +83,7 @@ func TestTeam_ReadOne(t *testing.T) { defer s.Close() team := &Team{ID: -1} - err := team.ReadOne(s) + err := team.ReadOne(s, u) assert.Error(t, err) assert.True(t, IsErrTeamDoesNotExist(err)) }) @@ -91,7 +93,7 @@ func TestTeam_ReadOne(t *testing.T) { defer s.Close() team := &Team{ID: 99999} - err := team.ReadOne(s) + err := team.ReadOne(s, u) assert.Error(t, err) assert.True(t, IsErrTeamDoesNotExist(err)) }) @@ -113,6 +115,8 @@ func TestTeam_ReadAll(t *testing.T) { } func TestTeam_Update(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -122,7 +126,7 @@ func TestTeam_Update(t *testing.T) { ID: 1, Name: "SomethingNew", } - err := team.Update(s) + err := team.Update(s, u) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) @@ -140,7 +144,7 @@ func TestTeam_Update(t *testing.T) { ID: 1, Name: "", } - err := team.Update(s) + err := team.Update(s, u) assert.Error(t, err) assert.True(t, IsErrTeamNameCannotBeEmpty(err)) }) @@ -153,13 +157,15 @@ func TestTeam_Update(t *testing.T) { ID: 9999, Name: "SomethingNew", } - err := team.Update(s) + err := team.Update(s, u) assert.Error(t, err) assert.True(t, IsErrTeamDoesNotExist(err)) }) } func TestTeam_Delete(t *testing.T) { + u := &user.User{ID: 1} + t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() @@ -168,7 +174,7 @@ func TestTeam_Delete(t *testing.T) { team := &Team{ ID: 1, } - err := team.Delete(s) + err := team.Delete(s, u) assert.NoError(t, err) err = s.Commit() assert.NoError(t, err) diff --git a/pkg/modules/auth/openid/main_test.go b/pkg/modules/auth/openid/main_test.go index 31ffdb02e..21b51d68e 100644 --- a/pkg/modules/auth/openid/main_test.go +++ b/pkg/modules/auth/openid/main_test.go @@ -20,6 +20,8 @@ import ( "os" "testing" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" @@ -30,5 +32,6 @@ func TestMain(m *testing.M) { user.InitTests() files.InitTests() models.SetupTests() + events.Fake() os.Exit(m.Run()) } diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index f39450019..a9f99f0bc 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -226,7 +226,7 @@ func InsertFromStructure(str []*models.NamespaceWithLists, user *user.User) (err return err } buckets := bucketsIn.([]*models.Bucket) - err = buckets[0].Delete(s) + err = buckets[0].Delete(s, user) if err != nil { _ = s.Rollback() return err diff --git a/pkg/modules/migration/main_test.go b/pkg/modules/migration/main_test.go index d2ce6746d..0ceca1559 100644 --- a/pkg/modules/migration/main_test.go +++ b/pkg/modules/migration/main_test.go @@ -20,6 +20,8 @@ import ( "os" "testing" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/models" @@ -37,5 +39,6 @@ func TestMain(m *testing.M) { files.InitTests() user.InitTests() models.SetupTests() + events.Fake() os.Exit(m.Run()) } diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 49328b33a..6a5ae7a89 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -104,7 +104,7 @@ func RenewToken(c echo.Context) (err error) { if typ == auth.AuthTypeLinkShare { share := &models.LinkSharing{} share.ID = int64(claims["id"].(float64)) - err := share.ReadOne(s) + err := share.ReadOne(s, share) if err != nil { _ = s.Rollback() return handler.HandleHTTPError(err, c) diff --git a/pkg/routes/api/v1/task_attachment.go b/pkg/routes/api/v1/task_attachment.go index fa44624a1..d74357b43 100644 --- a/pkg/routes/api/v1/task_attachment.go +++ b/pkg/routes/api/v1/task_attachment.go @@ -147,7 +147,7 @@ func GetTaskAttachment(c echo.Context) error { } // Get the attachment incl file - err = taskAttachment.ReadOne(s) + err = taskAttachment.ReadOne(s, auth) if err != nil { _ = s.Rollback() return handler.HandleHTTPError(err, c) diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go index bf9878a4b..64efaf53f 100644 --- a/pkg/routes/caldav/listStorageProvider.go +++ b/pkg/routes/caldav/listStorageProvider.go @@ -318,7 +318,7 @@ func (vcls *VikunjaCaldavListStorage) UpdateResource(rpath, content string) (*da } // Update the task - err = vTask.Update(s) + err = vTask.Update(s, vcls.user) if err != nil { _ = s.Rollback() return nil, err @@ -354,7 +354,7 @@ func (vcls *VikunjaCaldavListStorage) DeleteResource(rpath string) error { } // Delete it - err = vcls.task.Delete(s) + err = vcls.task.Delete(s, vcls.user) if err != nil { _ = s.Rollback() return err @@ -458,7 +458,7 @@ func (vcls *VikunjaCaldavListStorage) getListRessource(isCollection bool) (rr Vi log.Errorf("User %v tried to access a caldav resource (List %v) which they are not allowed to access", vcls.user.Username, vcls.list.ID) return rr, models.ErrUserDoesNotHaveAccessToList{ListID: vcls.list.ID} } - err = vcls.list.ReadOne(s) + err = vcls.list.ReadOne(s, vcls.user) if err != nil { _ = s.Rollback() return diff --git a/pkg/routes/metrics.go b/pkg/routes/metrics.go index 4cdef30e5..7c471bed7 100644 --- a/pkg/routes/metrics.go +++ b/pkg/routes/metrics.go @@ -71,7 +71,7 @@ func setupMetrics(a *echo.Group) { } } - a.GET("/metrics", echo.WrapHandler(promhttp.Handler())) + a.GET("/metrics", echo.WrapHandler(promhttp.HandlerFor(metrics.GetRegistry(), promhttp.HandlerOpts{}))) } func setupMetricsMiddleware(a *echo.Group) { diff --git a/pkg/user/events.go b/pkg/user/events.go new file mode 100644 index 000000000..0b07c9ea3 --- /dev/null +++ b/pkg/user/events.go @@ -0,0 +1,27 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package user + +// CreatedEvent represents a CreatedEvent event +type CreatedEvent struct { + User *User +} + +// TopicName defines the name for CreatedEvent +func (t *CreatedEvent) Name() string { + return "user.created" +} diff --git a/pkg/user/listeners.go b/pkg/user/listeners.go new file mode 100644 index 000000000..572461b2c --- /dev/null +++ b/pkg/user/listeners.go @@ -0,0 +1,45 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package user + +import ( + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/metrics" + "code.vikunja.io/api/pkg/modules/keyvalue" + "github.com/ThreeDotsLabs/watermill/message" +) + +func RegisterListeners() { + events.RegisterListener((&CreatedEvent{}).Name(), &IncreaseUserCounter{}) +} + +/////// +// User Events + +// IncreaseUserCounter represents a listener +type IncreaseUserCounter struct { +} + +// Name defines the name for the IncreaseUserCounter listener +func (s *IncreaseUserCounter) Name() string { + return "increase.user.counter" +} + +// Hanlde is executed when the event IncreaseUserCounter listens on is fired +func (s *IncreaseUserCounter) Handle(payload message.Payload) (err error) { + return keyvalue.IncrBy(metrics.UserCountKey, 1) +} diff --git a/pkg/user/test.go b/pkg/user/test.go index af35454cc..86efd6d5b 100644 --- a/pkg/user/test.go +++ b/pkg/user/test.go @@ -18,6 +18,7 @@ package user import ( "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" ) @@ -37,4 +38,6 @@ func InitTests() { if err != nil { log.Fatal(err) } + + events.Fake() } diff --git a/pkg/user/user_create.go b/pkg/user/user_create.go index fc4595f64..7163feb05 100644 --- a/pkg/user/user_create.go +++ b/pkg/user/user_create.go @@ -18,8 +18,8 @@ package user import ( "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/mail" - "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/utils" "golang.org/x/crypto/bcrypt" "xorm.io/xorm" @@ -70,15 +70,19 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) { return nil, err } - // Update the metrics - metrics.UpdateCount(1, metrics.ActiveUsersKey) - // Get the full new User newUserOut, err := GetUserByID(s, user.ID) if err != nil { return nil, err } + err = events.Dispatch(&CreatedEvent{ + User: newUserOut, + }) + if err != nil { + return nil, err + } + sendConfirmEmail(user) return newUserOut, err