├── .air.toml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── app ├── account_pages.go ├── api.go ├── config.go ├── email.go ├── errors.go ├── gen │ └── models │ │ ├── client.go │ │ ├── config.go │ │ ├── context.go │ │ ├── ent.go │ │ ├── enttest │ │ └── enttest.go │ │ ├── hook │ │ └── hook.go │ │ ├── migrate │ │ ├── migrate.go │ │ └── schema.go │ │ ├── mutation.go │ │ ├── predicate │ │ └── predicate.go │ │ ├── runtime.go │ │ ├── runtime │ │ └── runtime.go │ │ ├── task.go │ │ ├── task │ │ ├── task.go │ │ └── where.go │ │ ├── task_create.go │ │ ├── task_delete.go │ │ ├── task_query.go │ │ ├── task_update.go │ │ └── tx.go ├── generator │ ├── entc.go │ └── generate.go ├── router.go ├── schema │ └── task.go ├── stripe.go ├── task_pages.go └── webhook.go ├── assets ├── .babelrc ├── images │ └── logo.svg ├── package-lock.json ├── package.json ├── src │ ├── components │ │ ├── App.svelte │ │ ├── Groups.svelte │ │ ├── Guests.svelte │ │ ├── Members.svelte │ │ └── index.js │ ├── controllers │ │ ├── account_controller.js │ │ ├── clipboard_controller.js │ │ ├── hover_hidden_controller.js │ │ ├── navigate_controller.js │ │ ├── subscription_controller.js │ │ ├── svelte_controller.js │ │ ├── tabs_controller.js │ │ └── toggle_controller.js │ ├── index.js │ └── styles.scss ├── webpack.config.js └── yarn.lock ├── env.development ├── feature_groups.development.json ├── go.mod ├── go.sum ├── main.go ├── plans.development.json ├── stripe.md └── templates ├── 404.html ├── account ├── changed.html ├── confirmed.html ├── forgot.html ├── login.html ├── magic.html ├── main.html ├── main_workspace.html ├── reset.html └── signup.html ├── app.html ├── home.html ├── layouts ├── empty.html └── index.html └── partials ├── developer.html ├── errors.html ├── footer.html ├── groups.html ├── guests.html ├── header.html ├── list_members.html ├── members.html ├── navbar.html ├── plan.html ├── plans.html ├── profile.html └── workspace_switcher.html /.air.toml: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format 2 | 3 | # Working directory 4 | # . or absolute path, please note that the directories following must be under root. 5 | root = "." 6 | tmp_dir = "tmp" 7 | 8 | [build] 9 | # Just plain old shell command. You could use `make` as well. 10 | cmd = "go build -o ./tmp/main main.go" 11 | # Binary file yields from `cmd`. 12 | bin = "tmp/main" 13 | # Customize binary. 14 | full_bin = "APP_ENV=dev APP_USER=air ./tmp/main -config env.local" 15 | # Watch these filename extensions. 16 | include_ext = ["go", "tpl", "tmpl", "html"] 17 | # Ignore these filename extensions or directories. 18 | exclude_dir = ["web", "screenshots", "vendor"] 19 | # Watch these directories if you specified. 20 | include_dir = ["app","tasks"] 21 | # Exclude files. 22 | exclude_file = ["tasks.db",".gitignore"] 23 | # Exclude unchanged files. 24 | exclude_unchanged = true 25 | # This log file places in your tmp_dir. 26 | log = "air.log" 27 | # It's not necessary to trigger build each time file changes if it's too frequent. 28 | delay = 1000 # ms 29 | # Stop running old binary when build errors occur. 30 | stop_on_error = true 31 | # Send Interrupt signal before killing process (windows does not support this feature) 32 | send_interrupt = false 33 | # Delay after sending Interrupt signal 34 | kill_delay = 500 # ms 35 | 36 | [log] 37 | # Show log time 38 | time = false 39 | 40 | [color] 41 | # Customize each part's color. If no color found, use the raw app log. 42 | main = "magenta" 43 | watcher = "cyan" 44 | build = "yellow" 45 | runner = "green" 46 | 47 | [misc] 48 | # Delete tmp directory on exit 49 | clean_on_exit = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | assets/node_modules 3 | .idea 4 | *.db 5 | tmp 6 | .DS_Store 7 | bin 8 | *.local 9 | *.local.* 10 | public 11 | ops/build 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15.5-buster as build-go 2 | WORKDIR /go/src/app 3 | COPY . . 4 | RUN CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static"' -o main . 5 | 6 | FROM node:14.3.0-stretch as build-node 7 | WORKDIR /usr/src/app 8 | COPY . . 9 | RUN cd assets && npm install 10 | RUN cd assets && npm run build 11 | 12 | FROM alpine:latest 13 | RUN apk add ca-certificates curl 14 | WORKDIR /opt 15 | COPY --from=build-go /go/src/app/ /bin 16 | RUN chmod +x /bin/main 17 | COPY --from=build-node /usr/src/app/public /opt/public 18 | COPY --from=build-node /usr/src/app/templates /opt/templates 19 | CMD ["/bin/main"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adnaan Badr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | go get -v ./... && cd assets && yarn install 3 | watch: install 4 | echo "watching go files and assets directory..."; \ 5 | air -d -c .air.toml & \ 6 | cd assets && yarn watch & \ 7 | wait; \ 8 | echo "bye!" 9 | watch-go: 10 | air -c .air.toml 11 | run-go: 12 | go run main.go -config env.local 13 | watch-assets: 14 | cd assets && yarn watch 15 | build-docker: 16 | docker build -t gomodest . 17 | run-docker: 18 | docker run -it --rm -p 4000:4000 --env-file env.local gomodest:latest 19 | mailhog: 20 | docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog 21 | generate-models: 22 | go generate ./app/generator -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **GOMODEST** is a Multi Page App(MPA) **starter kit** using Go's `html/template`, `SvelteJS` and `StimulusJS`. It is inspired from modest approaches to building webapps as enlisted in https://modestjs.works/. 2 | 3 | ## Motivation 4 | 5 | I am a devops engineer who dabbles in UI for side projects, internal tools and such. The SPA/ReactJS ecosystem is too costly for me. This is an alternative approach. 6 | 7 | 8 | > The main idea is to use server rendered html with spots of client-side dynamism using SvelteJS & StimulusJS 9 | 10 | The webapp is mostly plain old javascript, html, css but with sprinkles of StimulusJS & spots of SvelteJS used for interactivity sans page reloads. StimulusJS is used for sprinkling 11 | interactivity in server rendered html & mounting Svelte components into divs. 12 | 13 | ## Stack 14 | 15 | A few things which were used: 16 | 17 | 1. Go, html/template, goview, 18 | 2. Authentication: [github.com/adnaan/authn](https://github.com/adnaan/authn) 19 | 3. SvelteJS 20 | 4. [Hotwire](https://hotwire.dev/) 21 | 5. Bulma CSS 22 | 23 | Many more things in `go.mod` & `web/package.json` 24 | 25 | To run, clone this repo and: 26 | 27 | ```bash 28 | $ make install 29 | # another terminal 30 | $ make run-go 31 | ``` 32 | 33 | The ideas in this starter kit follow the JS gradient as noted [here](https://modestjs.works/book/part-2/the-js-gradient/). I have taken the liberty to organise them into the following big blocks: **server-rendered html**, **sprinkles** and **spots**. 34 | 35 | ## Server Rendered HTML 36 | 37 | Use `html/template` and `goview` to render html pages. It's quite powerful when do you don't need client-side interactions. 38 | 39 | example: 40 | 41 | ```go 42 | func accountPage(w http.ResponseWriter, r *http.Request) (goview.M, error) { 43 | 44 | session, err := store.Get(r, "auth-session") 45 | if err != nil { 46 | return nil, fmt.Errorf("%v, %w", err, InternalErr) 47 | } 48 | 49 | profileData, ok := session.Values["profile"] 50 | if !ok { 51 | return nil, fmt.Errorf("%v, %w", err, InternalErr) 52 | } 53 | 54 | profile, ok := profileData.(map[string]interface{}) 55 | if !ok { 56 | return nil, fmt.Errorf("%v, %w", err, InternalErr) 57 | } 58 | 59 | return goview.M{ 60 | "name": profile["name"], 61 | }, nil 62 | 63 | } 64 | ``` 65 | 66 | ## Sprinkles 67 | 68 | Use `stimulusjs` to level up server-rendered html to handle simple interactions like: navigations, form validations etc. 69 | 70 | example: 71 | 72 | ```html 73 | 79 | ``` 80 | 81 | ```js 82 | goto(e){ 83 | if (e.currentTarget.dataset.goto){ 84 | window.location = e.currentTarget.dataset.goto; 85 | } 86 | } 87 | ``` 88 | 89 | ## Spots 90 | 91 | Use `sveltejs` to take over `spots` of a server-rendered html page to provide more complex interactivity without page reloads. 92 | 93 | This snippet is the most interesting part of this project: 94 | 95 | ```html 96 | {{define "content"}} 97 |
98 |
99 |
103 |
104 |
105 |
106 | 107 | {{end}} 108 | ``` 109 | 110 | [source](https://github.com/adnaan/gomodest-starter/blob/main/web/html/app.html) 111 | 112 | In the above snippet, we use StimulusJS to mount a Svelte component by using the following code: 113 | 114 | ```js 115 | import { Controller } from "stimulus"; 116 | import components from "./components"; 117 | 118 | export default class extends Controller { 119 | static targets = ["component"] 120 | connect() { 121 | if (this.componentTargets.length > 0){ 122 | this.componentTargets.forEach(el => { 123 | const componentName = el.dataset.componentName; 124 | const componentProps = el.dataset.componentProps ? JSON.parse(el.dataset.componentProps): {}; 125 | if (!(componentName in components)){ 126 | console.log(`svelte component: ${componentName}, not found!`) 127 | return; 128 | } 129 | const app = new components[componentName]({ 130 | target: el, 131 | props: componentProps 132 | }); 133 | }) 134 | } 135 | } 136 | } 137 | ``` 138 | [source](https://github.com/adnaan/gomodest-starter/blob/main/web/src/controllers/svelte_controller.js) 139 | 140 | This strategy allows us to mix server rendered HTML pages with client side dynamism. 141 | 142 | Other possibly interesting aspects could be the layout of [web/html](https://github.com/adnaan/gomodest-starter/tree/main/web/html) and the usage of the super nice [goview](https://github.com/foolin/goview) library to render html in these files: 143 | 144 | 1. [router.go](https://github.com/adnaan/gomodest-starter/blob/main/routes/router.go) 145 | 2. [view.go](https://github.com/adnaan/gomodest-starter/blob/main/routes/view.go) 146 | 3. [pages.go](https://github.com/adnaan/gomodest-starter/blob/main/routes/pages.go) 147 | 148 | That is all. 149 | 150 | --------------- 151 | 152 | 153 | ## Attributions 154 | 155 | 1. https://areknawo.com/making-a-todo-app-in-svelte/ 156 | 2. https://modestjs.works/ 157 | 3. https://github.com/foolin/goview 158 | 159 | 160 | 161 | -------------------------------------------------------------------------------- /app/account_pages.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/lithammer/shortuuid/v3" 10 | 11 | rl "github.com/adnaan/renderlayout" 12 | 13 | "github.com/stripe/stripe-go/v72" 14 | "github.com/stripe/stripe-go/v72/sub" 15 | 16 | "github.com/google/uuid" 17 | 18 | "github.com/go-chi/chi" 19 | ) 20 | 21 | func defaultPageHandler(appCtx Context) rl.Data { 22 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 23 | pageData := map[string]interface{}{} 24 | pageData["route"] = r.URL.Path 25 | pageData["app_name"] = strings.Title(strings.ToLower(appCtx.cfg.Name)) 26 | pageData["feature_groups"] = appCtx.cfg.FeatureGroups 27 | 28 | account, err := appCtx.authn.CurrentAccount(r) 29 | if err != nil { 30 | return pageData, nil 31 | } 32 | 33 | accAttributes := account.Attributes().Map() 34 | if _, ok := accAttributes["api_key"]; ok { 35 | pageData["is_api_token_set"] = true 36 | } 37 | 38 | pageData["is_logged_in"] = true 39 | pageData["email"] = account.Email() 40 | pageData["metadata"] = accAttributes 41 | 42 | currentPriceID, _ := account.Attributes().Session().Get(currentPriceIDKey) 43 | // get currentPriceID using stripe customer ID 44 | billingId, billingIDExists := accAttributes.String(billingIDKey) 45 | if billingIDExists && currentPriceID == nil { 46 | params := &stripe.SubscriptionListParams{ 47 | Customer: billingId, 48 | Status: string(stripe.SubscriptionStatusActive), 49 | } 50 | params.AddExpand("data.items.data.price") 51 | params.Filters.AddFilter("limit", "", "1") 52 | 53 | i := sub.List(params) 54 | for i.Next() { 55 | s := i.Subscription() 56 | if s.Status == stripe.SubscriptionStatusActive { 57 | for _, pr := range s.Items.Data { 58 | currentPriceID = pr.Price.ID 59 | } 60 | } 61 | 62 | } 63 | } 64 | 65 | if currentPriceID != nil { 66 | err = account.Attributes().Session().Set(w, currentPriceIDKey, currentPriceID) 67 | if err != nil { 68 | log.Println("SetSessionVal", err) 69 | } 70 | 71 | for _, plan := range appCtx.cfg.Plans { 72 | if plan.PriceID == currentPriceID.(string) { 73 | pageData["current_plan"] = Plan{ 74 | Current: true, 75 | PriceID: plan.PriceID, 76 | Name: plan.Name, 77 | Price: plan.Price, 78 | Details: plan.Details, 79 | StripeKey: plan.StripeKey, 80 | } 81 | } 82 | } 83 | 84 | } 85 | 86 | return pageData, nil 87 | } 88 | } 89 | 90 | func signupPage(appCtx Context) rl.Data { 91 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 92 | var email, password string 93 | metadata := make(map[string]interface{}) 94 | _ = r.ParseForm() 95 | for k, v := range r.Form { 96 | 97 | if k == "email" && len(v) == 0 { 98 | return rl.D{}, fmt.Errorf("email is required") 99 | } 100 | 101 | if k == "password" && len(v) == 0 { 102 | return rl.D{}, fmt.Errorf("password is required") 103 | } 104 | 105 | if len(v) == 0 { 106 | continue 107 | } 108 | 109 | if k == "email" && len(v) > 0 { 110 | email = v[0] 111 | continue 112 | } 113 | 114 | if k == "password" && len(v) > 0 { 115 | password = v[0] 116 | continue 117 | } 118 | 119 | if len(v) == 1 { 120 | metadata[k] = v[0] 121 | continue 122 | } 123 | if len(v) > 1 { 124 | metadata[k] = v 125 | } 126 | } 127 | 128 | err := appCtx.authn.Signup(r.Context(), email, password, metadata) 129 | if err != nil { 130 | return rl.D{}, err 131 | } 132 | 133 | http.Redirect(w, r, "/login?confirmation_sent=true", http.StatusSeeOther) 134 | 135 | return rl.D{}, nil 136 | } 137 | } 138 | 139 | func loginPage(appCtx Context) rl.Data { 140 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 141 | 142 | confirmed := r.URL.Query().Get("confirmed") 143 | if confirmed == "true" { 144 | return rl.D{ 145 | "confirmed": true, 146 | }, nil 147 | } 148 | 149 | notConfirmed := r.URL.Query().Get("not_confirmed") 150 | if notConfirmed == "true" { 151 | return rl.D{ 152 | "not_confirmed": true, 153 | }, nil 154 | } 155 | 156 | confirmationSent := r.URL.Query().Get("confirmation_sent") 157 | if confirmationSent == "true" { 158 | return rl.D{ 159 | "confirmation_sent": true, 160 | }, nil 161 | } 162 | 163 | emailChanged := r.URL.Query().Get("email_changed") 164 | if emailChanged == "true" { 165 | return rl.D{ 166 | "email_changed": true, 167 | }, nil 168 | } 169 | 170 | return rl.D{}, nil 171 | } 172 | } 173 | 174 | func loginPageSubmit(appCtx Context) rl.Data { 175 | type req struct { 176 | Email *string 177 | Password *string 178 | Magic *string 179 | } 180 | 181 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 182 | form := new(req) 183 | err := r.ParseForm() 184 | if err != nil { 185 | return nil, fmt.Errorf("%w", err) 186 | } 187 | 188 | err = appCtx.formDecoder.Decode(form, r.Form) 189 | if err != nil { 190 | return nil, fmt.Errorf("%w", err) 191 | } 192 | 193 | if form.Email == nil { 194 | return nil, fmt.Errorf("%w", fmt.Errorf("email is empty")) 195 | } 196 | 197 | if form.Magic != nil && *form.Magic == "magic" { 198 | err := appCtx.authn.SendPasswordlessToken(r.Context(), *form.Email) 199 | if err != nil { 200 | return nil, err 201 | } 202 | http.Redirect(w, r, "/magic-link-sent", http.StatusSeeOther) 203 | } else { 204 | if form.Password == nil { 205 | return nil, fmt.Errorf("%w", fmt.Errorf("password is empty")) 206 | } 207 | err := appCtx.authn.Login(w, r, *form.Email, *form.Password) 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | redirectTo := "/app" 213 | from := r.URL.Query().Get("from") 214 | if from != "" { 215 | redirectTo = from 216 | } 217 | 218 | http.Redirect(w, r, redirectTo, http.StatusSeeOther) 219 | } 220 | 221 | return rl.D{}, nil 222 | } 223 | } 224 | 225 | func magicLinkLoginConfirm(appCtx Context) rl.Data { 226 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 227 | otp := chi.URLParam(r, "otp") 228 | err := appCtx.authn.LoginWithPasswordlessToken(w, r, otp) 229 | if err != nil { 230 | return nil, err 231 | } 232 | 233 | redirectTo := "/app" 234 | 235 | http.Redirect(w, r, redirectTo, http.StatusSeeOther) 236 | 237 | return rl.D{}, nil 238 | } 239 | } 240 | 241 | func loginProviderCallbackPage(appCtx Context) rl.Data { 242 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 243 | err := appCtx.authn.LoginProviderCallback(w, r, nil) 244 | if err != nil { 245 | return rl.D{}, err 246 | } 247 | redirectTo := "/app" 248 | 249 | http.Redirect(w, r, redirectTo, http.StatusSeeOther) 250 | return rl.D{}, nil 251 | } 252 | } 253 | 254 | func loginProviderPage(appCtx Context) rl.Data { 255 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 256 | err := appCtx.authn.LoginWithProvider(w, r) 257 | if err != nil { 258 | return rl.D{}, err 259 | } 260 | redirectTo := "/app" 261 | from := r.URL.Query().Get("from") 262 | if from != "" { 263 | redirectTo = from 264 | } 265 | 266 | http.Redirect(w, r, redirectTo, http.StatusSeeOther) 267 | return rl.D{}, nil 268 | } 269 | } 270 | 271 | func confirmEmailChangePage(appCtx Context) rl.Data { 272 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 273 | token := chi.URLParam(r, "token") 274 | acc, err := appCtx.authn.CurrentAccount(r) 275 | if err != nil { 276 | return nil, err 277 | } 278 | err = acc.ConfirmEmailChange(token) 279 | if err != nil { 280 | return nil, err 281 | } 282 | http.Redirect(w, r, "/account?email_changed=true", http.StatusSeeOther) 283 | return rl.D{}, nil 284 | } 285 | } 286 | 287 | func confirmEmailPage(appCtx Context) rl.Data { 288 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 289 | token := chi.URLParam(r, "token") 290 | err := appCtx.authn.ConfirmSignupEmail(r.Context(), token) 291 | if err != nil { 292 | return nil, err 293 | } 294 | 295 | http.Redirect(w, r, "/login?confirmed=true", http.StatusSeeOther) 296 | return rl.D{}, nil 297 | } 298 | } 299 | 300 | func forgotPage(appCtx Context) rl.Data { 301 | type req struct { 302 | Email *string 303 | } 304 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 305 | form := new(req) 306 | err := r.ParseForm() 307 | if err != nil { 308 | return nil, fmt.Errorf("%w", err) 309 | } 310 | 311 | err = appCtx.formDecoder.Decode(form, r.Form) 312 | if err != nil { 313 | return nil, fmt.Errorf("%w", err) 314 | } 315 | 316 | if form.Email == nil { 317 | return nil, fmt.Errorf("%w", fmt.Errorf("email is empty")) 318 | } 319 | 320 | pageData := make(map[string]interface{}) 321 | 322 | err = appCtx.authn.Recovery(r.Context(), *form.Email) 323 | if err != nil { 324 | return pageData, err 325 | } 326 | 327 | pageData["recovery_sent"] = true 328 | 329 | return pageData, nil 330 | } 331 | } 332 | 333 | func resetPage(appCtx Context) rl.Data { 334 | type req struct { 335 | Password *string 336 | } 337 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 338 | token := chi.URLParam(r, "token") 339 | form := new(req) 340 | err := r.ParseForm() 341 | if err != nil { 342 | return nil, fmt.Errorf("%w", err) 343 | } 344 | 345 | err = appCtx.formDecoder.Decode(form, r.Form) 346 | if err != nil { 347 | return nil, fmt.Errorf("%w", err) 348 | } 349 | 350 | if form.Password == nil { 351 | return nil, fmt.Errorf("%w", fmt.Errorf("password is empty")) 352 | } 353 | 354 | err = appCtx.authn.ConfirmRecovery(r.Context(), token, *form.Password) 355 | if err != nil { 356 | return rl.D{}, err 357 | } 358 | 359 | http.Redirect(w, r, "/login", http.StatusSeeOther) 360 | 361 | return rl.D{}, nil 362 | } 363 | } 364 | 365 | func accountPage(appCtx Context) rl.Data { 366 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 367 | emailChanged := r.URL.Query().Get("email_changed") 368 | if emailChanged == "true" { 369 | return rl.D{ 370 | "form_token": uuid.New(), 371 | "email_changed": true, 372 | }, nil 373 | } 374 | 375 | checkout := r.URL.Query().Get("checkout") 376 | if checkout == "success" || checkout == "cancel" { 377 | return rl.D{ 378 | "checkout": checkout, 379 | "plans": appCtx.cfg.Plans, 380 | }, nil 381 | } 382 | 383 | return rl.D{ 384 | "form_token": uuid.New(), 385 | "plans": appCtx.cfg.Plans, 386 | }, nil 387 | } 388 | } 389 | 390 | func accountPageSubmit(appCtx Context) rl.Data { 391 | type req struct { 392 | Name *string 393 | Email *string 394 | ResetAPIToken bool 395 | FormToken *string 396 | } 397 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 398 | form := new(req) 399 | err := r.ParseForm() 400 | if err != nil { 401 | return nil, fmt.Errorf("%w", err) 402 | } 403 | 404 | err = appCtx.formDecoder.Decode(form, r.Form) 405 | if err != nil { 406 | return nil, fmt.Errorf("%w", err) 407 | } 408 | 409 | pageData := make(map[string]interface{}) 410 | 411 | account, err := appCtx.authn.CurrentAccount(r) 412 | if err != nil { 413 | return nil, err 414 | } 415 | 416 | if form.ResetAPIToken { 417 | // check if the form has been previously submitted 418 | if form.FormToken != nil { 419 | formTokenVal, err := account.Attributes().Session().Get("form_token") 420 | if err == nil && formTokenVal != nil { 421 | formToken := formTokenVal.(string) 422 | if formToken == *form.FormToken { 423 | return rl.D{}, nil 424 | } 425 | } 426 | } 427 | 428 | apiKey := shortuuid.New() 429 | token, err := appCtx.branca.EncodeToString(apiKey) 430 | if err != nil { 431 | return nil, fmt.Errorf("%v %w", err, ErrInternal) 432 | } 433 | 434 | err = account.Attributes().Set("api_key", apiKey) 435 | if err != nil { 436 | return nil, fmt.Errorf("%v %w", err, ErrInternal) 437 | } 438 | 439 | account.Attributes().Session().Set(w, "form_token", form.FormToken) 440 | return rl.D{ 441 | "is_api_token_set": true, 442 | "api_token": token, 443 | }, nil 444 | } 445 | 446 | if form.Email != nil && *form.Email != account.Email() { 447 | err = account.ChangeEmail(*form.Email) 448 | if err != nil { 449 | return nil, err 450 | } 451 | pageData["change_email"] = "requested" 452 | } 453 | 454 | name, _ := account.Attributes().Map().String("name") 455 | if name != *form.Name { 456 | err = account.Attributes().Set("name", form.Name) 457 | if err != nil { 458 | return nil, err 459 | } 460 | } 461 | 462 | account.Attributes().Session().Set(w, "form_token", form.FormToken) 463 | 464 | pageData["email"] = account.Email() 465 | pageData["metadata"] = account.Attributes().Map() 466 | return pageData, nil 467 | } 468 | } 469 | 470 | func deleteAccount(appCtx Context) rl.Data { 471 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 472 | account, err := appCtx.authn.CurrentAccount(r) 473 | if err != nil { 474 | return nil, err 475 | } 476 | err = account.Delete() 477 | if err != nil { 478 | return nil, err 479 | } 480 | http.Redirect(w, r, "/", http.StatusSeeOther) 481 | return rl.D{}, nil 482 | } 483 | } 484 | -------------------------------------------------------------------------------- /app/api.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/go-chi/chi" 8 | 9 | "github.com/lithammer/shortuuid/v3" 10 | 11 | "github.com/go-chi/render" 12 | 13 | "github.com/adnaan/authn" 14 | "github.com/adnaan/gomodest-starter/app/gen/models/task" 15 | ) 16 | 17 | func list(t Context) http.HandlerFunc { 18 | return func(w http.ResponseWriter, r *http.Request) { 19 | userID := authn.AccountIDFromContext(r) 20 | tasks, err := t.db.Task.Query().Where(task.Owner(userID)).All(r.Context()) 21 | if err != nil { 22 | render.Render(w, r, ErrInternal(err)) 23 | return 24 | } 25 | 26 | render.JSON(w, r, tasks) 27 | } 28 | } 29 | 30 | func create(t Context) http.HandlerFunc { 31 | type req struct { 32 | Text string `json:"text"` 33 | } 34 | return func(w http.ResponseWriter, r *http.Request) { 35 | req := new(req) 36 | userID := authn.AccountIDFromContext(r) 37 | err := render.DecodeJSON(r.Body, &req) 38 | if err != nil { 39 | render.Render(w, r, ErrInternal(err)) 40 | return 41 | } 42 | 43 | newTask, err := t.db.Task.Create(). 44 | SetID(shortuuid.New()). 45 | SetStatus(task.StatusInprogress). 46 | SetOwner(userID). 47 | SetText(req.Text). 48 | Save(r.Context()) 49 | if err != nil { 50 | render.Render(w, r, ErrInternal(err)) 51 | return 52 | } 53 | render.JSON(w, r, newTask) 54 | } 55 | } 56 | 57 | func updateStatus(t Context) http.HandlerFunc { 58 | type req struct { 59 | Status string `json:"status"` 60 | } 61 | return func(w http.ResponseWriter, r *http.Request) { 62 | req := new(req) 63 | id := chi.URLParam(r, "id") 64 | 65 | err := render.DecodeJSON(r.Body, &req) 66 | if err != nil { 67 | render.Render(w, r, ErrInternal(err)) 68 | return 69 | } 70 | 71 | updatedTask, err := t.db.Task. 72 | UpdateOneID(id). 73 | SetUpdatedAt(time.Now()). 74 | SetStatus(task.Status(req.Status)). 75 | Save(r.Context()) 76 | if err != nil { 77 | render.Render(w, r, ErrInternal(err)) 78 | return 79 | } 80 | render.JSON(w, r, updatedTask) 81 | } 82 | } 83 | 84 | func updateText(t Context) http.HandlerFunc { 85 | type req struct { 86 | Text string `json:"text"` 87 | } 88 | return func(w http.ResponseWriter, r *http.Request) { 89 | req := new(req) 90 | id := chi.URLParam(r, "id") 91 | 92 | err := render.DecodeJSON(r.Body, &req) 93 | if err != nil { 94 | render.Render(w, r, ErrInternal(err)) 95 | return 96 | } 97 | 98 | updatedTask, err := t.db.Task. 99 | UpdateOneID(id). 100 | SetUpdatedAt(time.Now()). 101 | SetText(req.Text). 102 | Save(r.Context()) 103 | if err != nil { 104 | render.Render(w, r, ErrInternal(err)) 105 | return 106 | } 107 | render.JSON(w, r, updatedTask) 108 | } 109 | } 110 | 111 | func delete(t Context) http.HandlerFunc { 112 | return func(w http.ResponseWriter, r *http.Request) { 113 | id := chi.URLParam(r, "id") 114 | err := t.db.Task.DeleteOneID(id).Exec(r.Context()) 115 | if err != nil { 116 | render.Render(w, r, ErrInternal(err)) 117 | return 118 | } 119 | render.Status(r, http.StatusOK) 120 | render.JSON(w, r, struct { 121 | Success bool `json:"success"` 122 | }{ 123 | Success: true, 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /app/config.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | 10 | "github.com/joho/godotenv" 11 | "github.com/kelseyhightower/envconfig" 12 | ) 13 | 14 | type Config struct { 15 | Name string `json:"name" default:"gomodest"` 16 | Domain string `json:"domain" default:"https://gomodest.xyz"` 17 | Port int `json:"port" default:"4000"` 18 | HealthPath string `json:"health_path" envconfig:"health_path" default:"/healthz"` 19 | ReadTimeoutSecs int `json:"read_timeout_secs" envconfig:"read_timeout_secs" default:"5"` 20 | WriteTimeoutSecs int `json:"write_timeout_secs" envconfig:"write_timeout_secs" default:"10"` 21 | LogLevel string `json:"log_level" envconfig:"log_level" default:"error"` 22 | LogFormatJSON bool `json:"log_format_json" envconfig:"log_format_json" default:"false"` 23 | Templates string `json:"templates" envconfig:"templates" default:"templates"` 24 | SessionSecret string `json:"session_secret" envconfig:"session_secret" default:"mysessionsecret"` 25 | APIMasterSecret string `json:"api_master_secret" envconfig:"api_master_secret" default:"supersecretkeyyoushouldnotcommit"` 26 | 27 | // datasource 28 | Driver string `json:"driver" envconfig:"driver" default:"sqlite3"` 29 | DataSource string `json:"datasource" envconfig:"datasource" default:"file:gomodest.db?mode=memory&cache=shared&_fk=1"` 30 | 31 | // smtp 32 | SMTPHost string `json:"smtp_host" envconfig:"smtp_host" default:"0.0.0.0"` 33 | SMTPPort int `json:"smtp_port,omitempty" envconfig:"smtp_port" default:"1025"` 34 | SMTPUser string `json:"smtp_user" envconfig:"smtp_user" default:"myuser" ` 35 | SMTPPass string `json:"smtp_pass,omitempty" envconfig:"smtp_pass" default:"mypass"` 36 | SMTPAdminEmail string `json:"smtp_admin_email" envconfig:"smtp_admin_email" default:"noreply@gomodest.xyz"` 37 | SMTPDebug bool `json:"smtp_debug" envconfig:"smtp_debug" default:"true"` 38 | 39 | // goth 40 | GoogleClientID string `json:"google_client_id" envconfig:"google_client_id"` 41 | GoogleSecret string `json:"google_secret" envconfig:"google_secret"` 42 | 43 | // subscription 44 | FeatureGroupsFile string `json:"feature_groups_file" envconfig:"feature_groups_file" default:"feature_groups.development.json"` 45 | FeatureGroups []FeatureGroup `json:"-" envconfig:"-"` 46 | PlansFile string `json:"plans_file" envconfig:"plans_file" default:"plans.development.json"` 47 | Plans []Plan `json:"-" envconfig:"-"` 48 | StripePublishableKey string `json:"stripe_publishable_key" envconfig:"stripe_publishable_key"` 49 | StripeSecretKey string `json:"stripe_secret_key" envconfig:"stripe_secret_key"` 50 | StripeWebhookSecret string `json:"stripe_webhook_secret" envconfig:"stripe_webhook_secret"` 51 | } 52 | 53 | type FeatureGroup struct { 54 | Name string `json:"name"` 55 | Features []Feature `json:"features"` 56 | } 57 | 58 | type Feature struct { 59 | ID string `json:"id"` 60 | Title string `json:"title"` 61 | ValueType string `json:"value_type"` 62 | } 63 | 64 | type Plan struct { 65 | PriceID string `json:"price_id"` 66 | Name string `json:"name"` 67 | Price string `json:"price"` 68 | Current bool `json:"-"` 69 | Details []string `json:"details"` 70 | Features map[string]interface{} `json:"features"` 71 | 72 | StripeKey string `json:"-"` 73 | } 74 | 75 | func LoadConfig(configFile string, envPrefix string) (Config, error) { 76 | var config Config 77 | if err := loadEnvironment(configFile); err != nil { 78 | return config, err 79 | } 80 | 81 | if err := envconfig.Process(envPrefix, &config); err != nil { 82 | return config, err 83 | } 84 | 85 | plans, err := loadPlans(config.PlansFile) 86 | if err == nil { 87 | for i := range plans { 88 | plans[i].StripeKey = config.StripePublishableKey 89 | } 90 | config.Plans = plans 91 | } else { 92 | fmt.Printf("err loading plan file %v, err %v \n", config.PlansFile, err) 93 | } 94 | 95 | featureGroups, err := loadFeatureGroups(config.FeatureGroupsFile) 96 | if err == nil { 97 | config.FeatureGroups = featureGroups 98 | } else { 99 | fmt.Printf("err loading feature groups file %v, err %v \n", config.FeatureGroupsFile, err) 100 | } 101 | 102 | return config, nil 103 | } 104 | 105 | func loadPlans(file string) ([]Plan, error) { 106 | if file == "" { 107 | return []Plan{}, nil 108 | } 109 | 110 | var data []byte 111 | var err error 112 | 113 | data, err = base64.StdEncoding.DecodeString(file) // check if string is base64 data 114 | if err != nil { 115 | data, err = ioutil.ReadFile(file) // or is a file path 116 | if err != nil { 117 | return nil, err 118 | } 119 | } 120 | 121 | var plans []Plan 122 | err = json.Unmarshal(data, &plans) 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | return plans, nil 128 | } 129 | 130 | func loadFeatureGroups(file string) ([]FeatureGroup, error) { 131 | if file == "" { 132 | return []FeatureGroup{}, nil 133 | } 134 | 135 | var data []byte 136 | var err error 137 | 138 | data, err = base64.StdEncoding.DecodeString(file) // check if string is base64 data 139 | if err != nil { 140 | data, err = ioutil.ReadFile(file) // or is a file path 141 | if err != nil { 142 | return nil, err 143 | } 144 | } 145 | 146 | var featureGroups []FeatureGroup 147 | err = json.Unmarshal(data, &featureGroups) 148 | if err != nil { 149 | return nil, err 150 | } 151 | 152 | return featureGroups, nil 153 | } 154 | 155 | func loadEnvironment(filename string) error { 156 | var err error 157 | if filename != "" { 158 | err = godotenv.Load(filename) 159 | } else { 160 | err = godotenv.Load() 161 | // handle if .env file does not exist, this is OK 162 | if os.IsNotExist(err) { 163 | return nil 164 | } 165 | } 166 | return err 167 | } 168 | 169 | func stringFromMap(m map[string]interface{}, k string) *string { 170 | v, ok := m[k] 171 | if !ok { 172 | return nil 173 | } 174 | val, ok := v.(string) 175 | if !ok { 176 | return nil 177 | } 178 | return &val 179 | } 180 | 181 | func int64FromMap(m map[string]interface{}, k string) *int64 { 182 | v, ok := m[k] 183 | if !ok { 184 | return nil 185 | } 186 | val, ok := v.(int64) 187 | if !ok { 188 | return nil 189 | } 190 | return &val 191 | } 192 | 193 | func float64FromMap(m map[string]interface{}, k string) *float64 { 194 | v, ok := m[k] 195 | if !ok { 196 | return nil 197 | } 198 | val, ok := v.(float64) 199 | if !ok { 200 | return nil 201 | } 202 | return &val 203 | } 204 | -------------------------------------------------------------------------------- /app/email.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "net/smtp" 6 | "net/textproto" 7 | "strings" 8 | "time" 9 | 10 | "github.com/adnaan/authn" 11 | 12 | "github.com/jordan-wright/email" 13 | 14 | "github.com/matcornic/hermes/v2" 15 | ) 16 | 17 | func sendEmailFunc(cfg Config) authn.SendMailFunc { 18 | appName := strings.Title(strings.ToLower(cfg.Name)) 19 | h := hermes.Hermes{ 20 | Product: hermes.Product{ 21 | Name: appName, 22 | Link: cfg.Domain, 23 | //Logo: "https://github.com/matcornic/hermes/blob/master/examples/gopher.png?raw=true", 24 | }, 25 | } 26 | 27 | pool := newEmailPool(cfg) 28 | return func(mailType authn.MailType, token, sendTo string, metadata map[string]interface{}) error { 29 | var name string 30 | var ok bool 31 | if metadata["name"] != nil { 32 | name, ok = metadata["name"].(string) 33 | if !ok { 34 | name = "" 35 | } 36 | } 37 | 38 | var emailTmpl hermes.Email 39 | var subject string 40 | 41 | switch mailType { 42 | case authn.Confirmation: 43 | subject = fmt.Sprintf("Welcome to %s!", appName) 44 | emailTmpl = confirmation(appName, name, fmt.Sprintf("%s/confirm/%s", cfg.Domain, token)) 45 | case authn.Recovery: 46 | subject = fmt.Sprintf("Reset password on %s", appName) 47 | emailTmpl = recovery(appName, name, fmt.Sprintf("%s/reset/%s", cfg.Domain, token)) 48 | case authn.ChangeEmail: 49 | subject = fmt.Sprintf("Change email on %s", appName) 50 | emailTmpl = changeEmail(appName, name, fmt.Sprintf("%s/change/%s", cfg.Domain, token)) 51 | case authn.Passwordless: 52 | subject = fmt.Sprintf("Magic link to log into %s", appName) 53 | emailTmpl = magic(appName, name, fmt.Sprintf("%s/magic-login/%s", cfg.Domain, token)) 54 | } 55 | 56 | res, err := h.GenerateHTML(emailTmpl) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | e := &email.Email{ 62 | To: []string{sendTo}, 63 | Subject: subject, 64 | HTML: []byte(res), 65 | Headers: textproto.MIMEHeader{}, 66 | From: cfg.SMTPAdminEmail, 67 | } 68 | 69 | return pool.Send(e, 20*time.Second) 70 | } 71 | } 72 | 73 | func confirmation(appName, name, link string) hermes.Email { 74 | return hermes.Email{ 75 | Body: hermes.Body{ 76 | Name: name, 77 | Intros: []string{ 78 | fmt.Sprintf("Welcome to %s! We're very excited to have you on board.", appName), 79 | }, 80 | Actions: []hermes.Action{ 81 | { 82 | Instructions: fmt.Sprintf("To get started with %s, please click here:", appName), 83 | Button: hermes.Button{ 84 | Text: "Confirm your account", 85 | Link: link, 86 | }, 87 | }, 88 | }, 89 | Outros: []string{ 90 | "Need help, or have questions? Just reply to this email, we'd love to help.", 91 | }, 92 | }, 93 | } 94 | } 95 | 96 | func changeEmail(appName, name, link string) hermes.Email { 97 | return hermes.Email{ 98 | Body: hermes.Body{ 99 | Name: name, 100 | Intros: []string{ 101 | fmt.Sprintf("You have received this email because you have requested to change the email linked to your %s account", appName), 102 | }, 103 | Actions: []hermes.Action{ 104 | { 105 | Instructions: fmt.Sprintf("Click the button below to change the email linked to your %s account", appName), 106 | Button: hermes.Button{ 107 | Color: "#DC4D2F", 108 | Text: "Confirm email change", 109 | Link: link, 110 | }, 111 | }, 112 | }, 113 | Outros: []string{ 114 | "If you did not request a email change, no further action is required on your part.", 115 | }, 116 | Signature: "Thanks", 117 | }, 118 | } 119 | } 120 | 121 | func recovery(appName, name, link string) hermes.Email { 122 | return hermes.Email{ 123 | Body: hermes.Body{ 124 | Name: name, 125 | Intros: []string{ 126 | fmt.Sprintf("You have received this email because a password reset request for %s account was received.", appName), 127 | }, 128 | Actions: []hermes.Action{ 129 | { 130 | Instructions: "Click the button below to reset your password:", 131 | Button: hermes.Button{ 132 | Color: "#DC4D2F", 133 | Text: "Reset your password", 134 | Link: link, 135 | }, 136 | }, 137 | }, 138 | Outros: []string{ 139 | "If you did not request a password reset, no further action is required on your part.", 140 | }, 141 | Signature: "Thanks", 142 | }, 143 | } 144 | } 145 | 146 | func magic(appName, name, link string) hermes.Email { 147 | return hermes.Email{ 148 | Body: hermes.Body{ 149 | Name: name, 150 | Intros: []string{ 151 | fmt.Sprintf("You have received this email because a request for a magic login link for your %s account was received.", appName), 152 | }, 153 | Actions: []hermes.Action{ 154 | { 155 | Instructions: "Click the button below to login:", 156 | Button: hermes.Button{ 157 | Text: "Login with magic link", 158 | Link: link, 159 | }, 160 | }, 161 | }, 162 | Outros: []string{ 163 | "If you did not request a magic login link, no further action is required on your part.", 164 | }, 165 | Signature: "Thanks", 166 | }, 167 | } 168 | } 169 | 170 | func newEmailPool(cfg Config) *email.Pool { 171 | 172 | var pool *email.Pool 173 | var err error 174 | 175 | if cfg.SMTPDebug { 176 | pool, err = email.NewPool( 177 | fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort), 178 | 10, &unencryptedAuth{ 179 | smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPHost)}, 180 | ) 181 | 182 | if err != nil { 183 | panic(err) 184 | } 185 | 186 | return pool 187 | } 188 | 189 | pool, err = email.NewPool( 190 | fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort), 191 | 10, 192 | smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPHost), 193 | ) 194 | 195 | if err != nil { 196 | panic(err) 197 | } 198 | 199 | return pool 200 | } 201 | 202 | type unencryptedAuth struct { 203 | smtp.Auth 204 | } 205 | 206 | // Start starts the auth process for the specified SMTP server. 207 | func (u *unencryptedAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { 208 | server.TLS = true 209 | return u.Auth.Start(server) 210 | } 211 | -------------------------------------------------------------------------------- /app/errors.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/go-chi/render" 8 | ) 9 | 10 | // copied from https://github.com/go-chi/chi/blob/master/_examples/rest/main.go#L389 11 | //-- 12 | // Error response payloads & renderers 13 | //-- 14 | 15 | // ErrResponse renderer type for handling all sorts of errors. 16 | // 17 | // In the best case scenario, the excellent github.com/pkg/errors package 18 | // helps reveal information on the error, setting it on Err, and in the Render() 19 | // method, using it to set the application-specific error code in AppCode. 20 | type ErrResponse struct { 21 | Err error `json:"-"` // low-level runtime error 22 | HTTPStatusCode int `json:"-"` // http response status code 23 | 24 | StatusText string `json:"status"` // user-level status message 25 | AppCode int64 `json:"code,omitempty"` // application-specific error code 26 | ErrorText string `json:"error,omitempty"` // application-level error message, for debugging 27 | } 28 | 29 | func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error { 30 | render.Status(r, e.HTTPStatusCode) 31 | return nil 32 | } 33 | 34 | func ErrInvalidRequest(err error) render.Renderer { 35 | return &ErrResponse{ 36 | Err: err, 37 | HTTPStatusCode: 400, 38 | StatusText: "Invalid request.", 39 | ErrorText: fmt.Sprintf("%v", err), 40 | } 41 | } 42 | 43 | func ErrRender(err error) render.Renderer { 44 | return &ErrResponse{ 45 | Err: err, 46 | HTTPStatusCode: 422, 47 | StatusText: "Error rendering response.", 48 | ErrorText: fmt.Sprintf("%v", err), 49 | } 50 | } 51 | 52 | func ErrInternal(err error) render.Renderer { 53 | return &ErrResponse{ 54 | Err: err, 55 | HTTPStatusCode: 500, 56 | StatusText: "Internal error.", 57 | ErrorText: fmt.Sprintf("%v", err), 58 | } 59 | } 60 | 61 | func ErrUnauthorized(err error) render.Renderer { 62 | return &ErrResponse{ 63 | Err: err, 64 | HTTPStatusCode: 401, 65 | StatusText: "Unauthorized", 66 | ErrorText: fmt.Sprintf("%v", err), 67 | } 68 | } 69 | 70 | var ErrNotFound = &ErrResponse{HTTPStatusCode: 404, StatusText: "Resource not found."} 71 | -------------------------------------------------------------------------------- /app/gen/models/client.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "log" 9 | 10 | "github.com/adnaan/gomodest-starter/app/gen/models/migrate" 11 | 12 | "github.com/adnaan/gomodest-starter/app/gen/models/task" 13 | 14 | "entgo.io/ent/dialect" 15 | "entgo.io/ent/dialect/sql" 16 | ) 17 | 18 | // Client is the client that holds all ent builders. 19 | type Client struct { 20 | config 21 | // Schema is the client for creating, migrating and dropping schema. 22 | Schema *migrate.Schema 23 | // Task is the client for interacting with the Task builders. 24 | Task *TaskClient 25 | } 26 | 27 | // NewClient creates a new client configured with the given options. 28 | func NewClient(opts ...Option) *Client { 29 | cfg := config{log: log.Println, hooks: &hooks{}} 30 | cfg.options(opts...) 31 | client := &Client{config: cfg} 32 | client.init() 33 | return client 34 | } 35 | 36 | func (c *Client) init() { 37 | c.Schema = migrate.NewSchema(c.driver) 38 | c.Task = NewTaskClient(c.config) 39 | } 40 | 41 | // Open opens a database/sql.DB specified by the driver name and 42 | // the data source name, and returns a new client attached to it. 43 | // Optional parameters can be added for configuring the client. 44 | func Open(driverName, dataSourceName string, options ...Option) (*Client, error) { 45 | switch driverName { 46 | case dialect.MySQL, dialect.Postgres, dialect.SQLite: 47 | drv, err := sql.Open(driverName, dataSourceName) 48 | if err != nil { 49 | return nil, err 50 | } 51 | return NewClient(append(options, Driver(drv))...), nil 52 | default: 53 | return nil, fmt.Errorf("unsupported driver: %q", driverName) 54 | } 55 | } 56 | 57 | // Tx returns a new transactional client. The provided context 58 | // is used until the transaction is committed or rolled back. 59 | func (c *Client) Tx(ctx context.Context) (*Tx, error) { 60 | if _, ok := c.driver.(*txDriver); ok { 61 | return nil, fmt.Errorf("models: cannot start a transaction within a transaction") 62 | } 63 | tx, err := newTx(ctx, c.driver) 64 | if err != nil { 65 | return nil, fmt.Errorf("models: starting a transaction: %w", err) 66 | } 67 | cfg := c.config 68 | cfg.driver = tx 69 | return &Tx{ 70 | ctx: ctx, 71 | config: cfg, 72 | Task: NewTaskClient(cfg), 73 | }, nil 74 | } 75 | 76 | // BeginTx returns a transactional client with specified options. 77 | func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) { 78 | if _, ok := c.driver.(*txDriver); ok { 79 | return nil, fmt.Errorf("ent: cannot start a transaction within a transaction") 80 | } 81 | tx, err := c.driver.(interface { 82 | BeginTx(context.Context, *sql.TxOptions) (dialect.Tx, error) 83 | }).BeginTx(ctx, opts) 84 | if err != nil { 85 | return nil, fmt.Errorf("ent: starting a transaction: %w", err) 86 | } 87 | cfg := c.config 88 | cfg.driver = &txDriver{tx: tx, drv: c.driver} 89 | return &Tx{ 90 | config: cfg, 91 | Task: NewTaskClient(cfg), 92 | }, nil 93 | } 94 | 95 | // Debug returns a new debug-client. It's used to get verbose logging on specific operations. 96 | // 97 | // client.Debug(). 98 | // Task. 99 | // Query(). 100 | // Count(ctx) 101 | // 102 | func (c *Client) Debug() *Client { 103 | if c.debug { 104 | return c 105 | } 106 | cfg := c.config 107 | cfg.driver = dialect.Debug(c.driver, c.log) 108 | client := &Client{config: cfg} 109 | client.init() 110 | return client 111 | } 112 | 113 | // Close closes the database connection and prevents new queries from starting. 114 | func (c *Client) Close() error { 115 | return c.driver.Close() 116 | } 117 | 118 | // Use adds the mutation hooks to all the entity clients. 119 | // In order to add hooks to a specific client, call: `client.Node.Use(...)`. 120 | func (c *Client) Use(hooks ...Hook) { 121 | c.Task.Use(hooks...) 122 | } 123 | 124 | // TaskClient is a client for the Task schema. 125 | type TaskClient struct { 126 | config 127 | } 128 | 129 | // NewTaskClient returns a client for the Task from the given config. 130 | func NewTaskClient(c config) *TaskClient { 131 | return &TaskClient{config: c} 132 | } 133 | 134 | // Use adds a list of mutation hooks to the hooks stack. 135 | // A call to `Use(f, g, h)` equals to `task.Hooks(f(g(h())))`. 136 | func (c *TaskClient) Use(hooks ...Hook) { 137 | c.hooks.Task = append(c.hooks.Task, hooks...) 138 | } 139 | 140 | // Create returns a create builder for Task. 141 | func (c *TaskClient) Create() *TaskCreate { 142 | mutation := newTaskMutation(c.config, OpCreate) 143 | return &TaskCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} 144 | } 145 | 146 | // CreateBulk returns a builder for creating a bulk of Task entities. 147 | func (c *TaskClient) CreateBulk(builders ...*TaskCreate) *TaskCreateBulk { 148 | return &TaskCreateBulk{config: c.config, builders: builders} 149 | } 150 | 151 | // Update returns an update builder for Task. 152 | func (c *TaskClient) Update() *TaskUpdate { 153 | mutation := newTaskMutation(c.config, OpUpdate) 154 | return &TaskUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} 155 | } 156 | 157 | // UpdateOne returns an update builder for the given entity. 158 | func (c *TaskClient) UpdateOne(t *Task) *TaskUpdateOne { 159 | mutation := newTaskMutation(c.config, OpUpdateOne, withTask(t)) 160 | return &TaskUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} 161 | } 162 | 163 | // UpdateOneID returns an update builder for the given id. 164 | func (c *TaskClient) UpdateOneID(id string) *TaskUpdateOne { 165 | mutation := newTaskMutation(c.config, OpUpdateOne, withTaskID(id)) 166 | return &TaskUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} 167 | } 168 | 169 | // Delete returns a delete builder for Task. 170 | func (c *TaskClient) Delete() *TaskDelete { 171 | mutation := newTaskMutation(c.config, OpDelete) 172 | return &TaskDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} 173 | } 174 | 175 | // DeleteOne returns a delete builder for the given entity. 176 | func (c *TaskClient) DeleteOne(t *Task) *TaskDeleteOne { 177 | return c.DeleteOneID(t.ID) 178 | } 179 | 180 | // DeleteOneID returns a delete builder for the given id. 181 | func (c *TaskClient) DeleteOneID(id string) *TaskDeleteOne { 182 | builder := c.Delete().Where(task.ID(id)) 183 | builder.mutation.id = &id 184 | builder.mutation.op = OpDeleteOne 185 | return &TaskDeleteOne{builder} 186 | } 187 | 188 | // Query returns a query builder for Task. 189 | func (c *TaskClient) Query() *TaskQuery { 190 | return &TaskQuery{config: c.config} 191 | } 192 | 193 | // Get returns a Task entity by its id. 194 | func (c *TaskClient) Get(ctx context.Context, id string) (*Task, error) { 195 | return c.Query().Where(task.ID(id)).Only(ctx) 196 | } 197 | 198 | // GetX is like Get, but panics if an error occurs. 199 | func (c *TaskClient) GetX(ctx context.Context, id string) *Task { 200 | obj, err := c.Get(ctx, id) 201 | if err != nil { 202 | panic(err) 203 | } 204 | return obj 205 | } 206 | 207 | // Hooks returns the client hooks. 208 | func (c *TaskClient) Hooks() []Hook { 209 | return c.hooks.Task 210 | } 211 | -------------------------------------------------------------------------------- /app/gen/models/config.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "entgo.io/ent" 7 | "entgo.io/ent/dialect" 8 | ) 9 | 10 | // Option function to configure the client. 11 | type Option func(*config) 12 | 13 | // Config is the configuration for the client and its builder. 14 | type config struct { 15 | // driver used for executing database requests. 16 | driver dialect.Driver 17 | // debug enable a debug logging. 18 | debug bool 19 | // log used for logging on debug mode. 20 | log func(...interface{}) 21 | // hooks to execute on mutations. 22 | hooks *hooks 23 | } 24 | 25 | // hooks per client, for fast access. 26 | type hooks struct { 27 | Task []ent.Hook 28 | } 29 | 30 | // Options applies the options on the config object. 31 | func (c *config) options(opts ...Option) { 32 | for _, opt := range opts { 33 | opt(c) 34 | } 35 | if c.debug { 36 | c.driver = dialect.Debug(c.driver, c.log) 37 | } 38 | } 39 | 40 | // Debug enables debug logging on the ent.Driver. 41 | func Debug() Option { 42 | return func(c *config) { 43 | c.debug = true 44 | } 45 | } 46 | 47 | // Log sets the logging function for debug mode. 48 | func Log(fn func(...interface{})) Option { 49 | return func(c *config) { 50 | c.log = fn 51 | } 52 | } 53 | 54 | // Driver configures the client driver. 55 | func Driver(driver dialect.Driver) Option { 56 | return func(c *config) { 57 | c.driver = driver 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/gen/models/context.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "context" 7 | ) 8 | 9 | type clientCtxKey struct{} 10 | 11 | // FromContext returns a Client stored inside a context, or nil if there isn't one. 12 | func FromContext(ctx context.Context) *Client { 13 | c, _ := ctx.Value(clientCtxKey{}).(*Client) 14 | return c 15 | } 16 | 17 | // NewContext returns a new context with the given Client attached. 18 | func NewContext(parent context.Context, c *Client) context.Context { 19 | return context.WithValue(parent, clientCtxKey{}, c) 20 | } 21 | 22 | type txCtxKey struct{} 23 | 24 | // TxFromContext returns a Tx stored inside a context, or nil if there isn't one. 25 | func TxFromContext(ctx context.Context) *Tx { 26 | tx, _ := ctx.Value(txCtxKey{}).(*Tx) 27 | return tx 28 | } 29 | 30 | // NewTxContext returns a new context with the given Tx attached. 31 | func NewTxContext(parent context.Context, tx *Tx) context.Context { 32 | return context.WithValue(parent, txCtxKey{}, tx) 33 | } 34 | -------------------------------------------------------------------------------- /app/gen/models/ent.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | 9 | "entgo.io/ent" 10 | "entgo.io/ent/dialect" 11 | "entgo.io/ent/dialect/sql" 12 | "entgo.io/ent/dialect/sql/sqlgraph" 13 | ) 14 | 15 | // ent aliases to avoid import conflicts in user's code. 16 | type ( 17 | Op = ent.Op 18 | Hook = ent.Hook 19 | Value = ent.Value 20 | Query = ent.Query 21 | Policy = ent.Policy 22 | Mutator = ent.Mutator 23 | Mutation = ent.Mutation 24 | MutateFunc = ent.MutateFunc 25 | ) 26 | 27 | // OrderFunc applies an ordering on the sql selector. 28 | type OrderFunc func(*sql.Selector, func(string) bool) 29 | 30 | // Asc applies the given fields in ASC order. 31 | func Asc(fields ...string) OrderFunc { 32 | return func(s *sql.Selector, check func(string) bool) { 33 | for _, f := range fields { 34 | if check(f) { 35 | s.OrderBy(sql.Asc(f)) 36 | } else { 37 | s.AddError(&ValidationError{Name: f, err: fmt.Errorf("invalid field %q for ordering", f)}) 38 | } 39 | } 40 | } 41 | } 42 | 43 | // Desc applies the given fields in DESC order. 44 | func Desc(fields ...string) OrderFunc { 45 | return func(s *sql.Selector, check func(string) bool) { 46 | for _, f := range fields { 47 | if check(f) { 48 | s.OrderBy(sql.Desc(f)) 49 | } else { 50 | s.AddError(&ValidationError{Name: f, err: fmt.Errorf("invalid field %q for ordering", f)}) 51 | } 52 | } 53 | } 54 | } 55 | 56 | // AggregateFunc applies an aggregation step on the group-by traversal/selector. 57 | type AggregateFunc func(*sql.Selector, func(string) bool) string 58 | 59 | // As is a pseudo aggregation function for renaming another other functions with custom names. For example: 60 | // 61 | // GroupBy(field1, field2). 62 | // Aggregate(models.As(models.Sum(field1), "sum_field1"), (models.As(models.Sum(field2), "sum_field2")). 63 | // Scan(ctx, &v) 64 | // 65 | func As(fn AggregateFunc, end string) AggregateFunc { 66 | return func(s *sql.Selector, check func(string) bool) string { 67 | return sql.As(fn(s, check), end) 68 | } 69 | } 70 | 71 | // Count applies the "count" aggregation function on each group. 72 | func Count() AggregateFunc { 73 | return func(s *sql.Selector, _ func(string) bool) string { 74 | return sql.Count("*") 75 | } 76 | } 77 | 78 | // Max applies the "max" aggregation function on the given field of each group. 79 | func Max(field string) AggregateFunc { 80 | return func(s *sql.Selector, check func(string) bool) string { 81 | if !check(field) { 82 | s.AddError(&ValidationError{Name: field, err: fmt.Errorf("invalid field %q for grouping", field)}) 83 | return "" 84 | } 85 | return sql.Max(s.C(field)) 86 | } 87 | } 88 | 89 | // Mean applies the "mean" aggregation function on the given field of each group. 90 | func Mean(field string) AggregateFunc { 91 | return func(s *sql.Selector, check func(string) bool) string { 92 | if !check(field) { 93 | s.AddError(&ValidationError{Name: field, err: fmt.Errorf("invalid field %q for grouping", field)}) 94 | return "" 95 | } 96 | return sql.Avg(s.C(field)) 97 | } 98 | } 99 | 100 | // Min applies the "min" aggregation function on the given field of each group. 101 | func Min(field string) AggregateFunc { 102 | return func(s *sql.Selector, check func(string) bool) string { 103 | if !check(field) { 104 | s.AddError(&ValidationError{Name: field, err: fmt.Errorf("invalid field %q for grouping", field)}) 105 | return "" 106 | } 107 | return sql.Min(s.C(field)) 108 | } 109 | } 110 | 111 | // Sum applies the "sum" aggregation function on the given field of each group. 112 | func Sum(field string) AggregateFunc { 113 | return func(s *sql.Selector, check func(string) bool) string { 114 | if !check(field) { 115 | s.AddError(&ValidationError{Name: field, err: fmt.Errorf("invalid field %q for grouping", field)}) 116 | return "" 117 | } 118 | return sql.Sum(s.C(field)) 119 | } 120 | } 121 | 122 | // ValidationError returns when validating a field fails. 123 | type ValidationError struct { 124 | Name string // Field or edge name. 125 | err error 126 | } 127 | 128 | // Error implements the error interface. 129 | func (e *ValidationError) Error() string { 130 | return e.err.Error() 131 | } 132 | 133 | // Unwrap implements the errors.Wrapper interface. 134 | func (e *ValidationError) Unwrap() error { 135 | return e.err 136 | } 137 | 138 | // IsValidationError returns a boolean indicating whether the error is a validaton error. 139 | func IsValidationError(err error) bool { 140 | if err == nil { 141 | return false 142 | } 143 | var e *ValidationError 144 | return errors.As(err, &e) 145 | } 146 | 147 | // NotFoundError returns when trying to fetch a specific entity and it was not found in the database. 148 | type NotFoundError struct { 149 | label string 150 | } 151 | 152 | // Error implements the error interface. 153 | func (e *NotFoundError) Error() string { 154 | return "models: " + e.label + " not found" 155 | } 156 | 157 | // IsNotFound returns a boolean indicating whether the error is a not found error. 158 | func IsNotFound(err error) bool { 159 | if err == nil { 160 | return false 161 | } 162 | var e *NotFoundError 163 | return errors.As(err, &e) 164 | } 165 | 166 | // MaskNotFound masks not found error. 167 | func MaskNotFound(err error) error { 168 | if IsNotFound(err) { 169 | return nil 170 | } 171 | return err 172 | } 173 | 174 | // NotSingularError returns when trying to fetch a singular entity and more then one was found in the database. 175 | type NotSingularError struct { 176 | label string 177 | } 178 | 179 | // Error implements the error interface. 180 | func (e *NotSingularError) Error() string { 181 | return "models: " + e.label + " not singular" 182 | } 183 | 184 | // IsNotSingular returns a boolean indicating whether the error is a not singular error. 185 | func IsNotSingular(err error) bool { 186 | if err == nil { 187 | return false 188 | } 189 | var e *NotSingularError 190 | return errors.As(err, &e) 191 | } 192 | 193 | // NotLoadedError returns when trying to get a node that was not loaded by the query. 194 | type NotLoadedError struct { 195 | edge string 196 | } 197 | 198 | // Error implements the error interface. 199 | func (e *NotLoadedError) Error() string { 200 | return "models: " + e.edge + " edge was not loaded" 201 | } 202 | 203 | // IsNotLoaded returns a boolean indicating whether the error is a not loaded error. 204 | func IsNotLoaded(err error) bool { 205 | if err == nil { 206 | return false 207 | } 208 | var e *NotLoadedError 209 | return errors.As(err, &e) 210 | } 211 | 212 | // ConstraintError returns when trying to create/update one or more entities and 213 | // one or more of their constraints failed. For example, violation of edge or 214 | // field uniqueness. 215 | type ConstraintError struct { 216 | msg string 217 | wrap error 218 | } 219 | 220 | // Error implements the error interface. 221 | func (e ConstraintError) Error() string { 222 | return "models: constraint failed: " + e.msg 223 | } 224 | 225 | // Unwrap implements the errors.Wrapper interface. 226 | func (e *ConstraintError) Unwrap() error { 227 | return e.wrap 228 | } 229 | 230 | // IsConstraintError returns a boolean indicating whether the error is a constraint failure. 231 | func IsConstraintError(err error) bool { 232 | if err == nil { 233 | return false 234 | } 235 | var e *ConstraintError 236 | return errors.As(err, &e) 237 | } 238 | 239 | func isSQLConstraintError(err error) (*ConstraintError, bool) { 240 | if sqlgraph.IsConstraintError(err) { 241 | return &ConstraintError{err.Error(), err}, true 242 | } 243 | return nil, false 244 | } 245 | 246 | // rollback calls tx.Rollback and wraps the given error with the rollback error if present. 247 | func rollback(tx dialect.Tx, err error) error { 248 | if rerr := tx.Rollback(); rerr != nil { 249 | err = fmt.Errorf("%w: %v", err, rerr) 250 | } 251 | if err, ok := isSQLConstraintError(err); ok { 252 | return err 253 | } 254 | return err 255 | } 256 | -------------------------------------------------------------------------------- /app/gen/models/enttest/enttest.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package enttest 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/adnaan/gomodest-starter/app/gen/models" 9 | // required by schema hooks. 10 | _ "github.com/adnaan/gomodest-starter/app/gen/models/runtime" 11 | 12 | "entgo.io/ent/dialect/sql/schema" 13 | ) 14 | 15 | type ( 16 | // TestingT is the interface that is shared between 17 | // testing.T and testing.B and used by enttest. 18 | TestingT interface { 19 | FailNow() 20 | Error(...interface{}) 21 | } 22 | 23 | // Option configures client creation. 24 | Option func(*options) 25 | 26 | options struct { 27 | opts []models.Option 28 | migrateOpts []schema.MigrateOption 29 | } 30 | ) 31 | 32 | // WithOptions forwards options to client creation. 33 | func WithOptions(opts ...models.Option) Option { 34 | return func(o *options) { 35 | o.opts = append(o.opts, opts...) 36 | } 37 | } 38 | 39 | // WithMigrateOptions forwards options to auto migration. 40 | func WithMigrateOptions(opts ...schema.MigrateOption) Option { 41 | return func(o *options) { 42 | o.migrateOpts = append(o.migrateOpts, opts...) 43 | } 44 | } 45 | 46 | func newOptions(opts []Option) *options { 47 | o := &options{} 48 | for _, opt := range opts { 49 | opt(o) 50 | } 51 | return o 52 | } 53 | 54 | // Open calls models.Open and auto-run migration. 55 | func Open(t TestingT, driverName, dataSourceName string, opts ...Option) *models.Client { 56 | o := newOptions(opts) 57 | c, err := models.Open(driverName, dataSourceName, o.opts...) 58 | if err != nil { 59 | t.Error(err) 60 | t.FailNow() 61 | } 62 | if err := c.Schema.Create(context.Background(), o.migrateOpts...); err != nil { 63 | t.Error(err) 64 | t.FailNow() 65 | } 66 | return c 67 | } 68 | 69 | // NewClient calls models.NewClient and auto-run migration. 70 | func NewClient(t TestingT, opts ...Option) *models.Client { 71 | o := newOptions(opts) 72 | c := models.NewClient(o.opts...) 73 | if err := c.Schema.Create(context.Background(), o.migrateOpts...); err != nil { 74 | t.Error(err) 75 | t.FailNow() 76 | } 77 | return c 78 | } 79 | -------------------------------------------------------------------------------- /app/gen/models/hook/hook.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package hook 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/adnaan/gomodest-starter/app/gen/models" 10 | ) 11 | 12 | // The TaskFunc type is an adapter to allow the use of ordinary 13 | // function as Task mutator. 14 | type TaskFunc func(context.Context, *models.TaskMutation) (models.Value, error) 15 | 16 | // Mutate calls f(ctx, m). 17 | func (f TaskFunc) Mutate(ctx context.Context, m models.Mutation) (models.Value, error) { 18 | mv, ok := m.(*models.TaskMutation) 19 | if !ok { 20 | return nil, fmt.Errorf("unexpected mutation type %T. expect *models.TaskMutation", m) 21 | } 22 | return f(ctx, mv) 23 | } 24 | 25 | // Condition is a hook condition function. 26 | type Condition func(context.Context, models.Mutation) bool 27 | 28 | // And groups conditions with the AND operator. 29 | func And(first, second Condition, rest ...Condition) Condition { 30 | return func(ctx context.Context, m models.Mutation) bool { 31 | if !first(ctx, m) || !second(ctx, m) { 32 | return false 33 | } 34 | for _, cond := range rest { 35 | if !cond(ctx, m) { 36 | return false 37 | } 38 | } 39 | return true 40 | } 41 | } 42 | 43 | // Or groups conditions with the OR operator. 44 | func Or(first, second Condition, rest ...Condition) Condition { 45 | return func(ctx context.Context, m models.Mutation) bool { 46 | if first(ctx, m) || second(ctx, m) { 47 | return true 48 | } 49 | for _, cond := range rest { 50 | if cond(ctx, m) { 51 | return true 52 | } 53 | } 54 | return false 55 | } 56 | } 57 | 58 | // Not negates a given condition. 59 | func Not(cond Condition) Condition { 60 | return func(ctx context.Context, m models.Mutation) bool { 61 | return !cond(ctx, m) 62 | } 63 | } 64 | 65 | // HasOp is a condition testing mutation operation. 66 | func HasOp(op models.Op) Condition { 67 | return func(_ context.Context, m models.Mutation) bool { 68 | return m.Op().Is(op) 69 | } 70 | } 71 | 72 | // HasAddedFields is a condition validating `.AddedField` on fields. 73 | func HasAddedFields(field string, fields ...string) Condition { 74 | return func(_ context.Context, m models.Mutation) bool { 75 | if _, exists := m.AddedField(field); !exists { 76 | return false 77 | } 78 | for _, field := range fields { 79 | if _, exists := m.AddedField(field); !exists { 80 | return false 81 | } 82 | } 83 | return true 84 | } 85 | } 86 | 87 | // HasClearedFields is a condition validating `.FieldCleared` on fields. 88 | func HasClearedFields(field string, fields ...string) Condition { 89 | return func(_ context.Context, m models.Mutation) bool { 90 | if exists := m.FieldCleared(field); !exists { 91 | return false 92 | } 93 | for _, field := range fields { 94 | if exists := m.FieldCleared(field); !exists { 95 | return false 96 | } 97 | } 98 | return true 99 | } 100 | } 101 | 102 | // HasFields is a condition validating `.Field` on fields. 103 | func HasFields(field string, fields ...string) Condition { 104 | return func(_ context.Context, m models.Mutation) bool { 105 | if _, exists := m.Field(field); !exists { 106 | return false 107 | } 108 | for _, field := range fields { 109 | if _, exists := m.Field(field); !exists { 110 | return false 111 | } 112 | } 113 | return true 114 | } 115 | } 116 | 117 | // If executes the given hook under condition. 118 | // 119 | // hook.If(ComputeAverage, And(HasFields(...), HasAddedFields(...))) 120 | // 121 | func If(hk models.Hook, cond Condition) models.Hook { 122 | return func(next models.Mutator) models.Mutator { 123 | return models.MutateFunc(func(ctx context.Context, m models.Mutation) (models.Value, error) { 124 | if cond(ctx, m) { 125 | return hk(next).Mutate(ctx, m) 126 | } 127 | return next.Mutate(ctx, m) 128 | }) 129 | } 130 | } 131 | 132 | // On executes the given hook only for the given operation. 133 | // 134 | // hook.On(Log, models.Delete|models.Create) 135 | // 136 | func On(hk models.Hook, op models.Op) models.Hook { 137 | return If(hk, HasOp(op)) 138 | } 139 | 140 | // Unless skips the given hook only for the given operation. 141 | // 142 | // hook.Unless(Log, models.Update|models.UpdateOne) 143 | // 144 | func Unless(hk models.Hook, op models.Op) models.Hook { 145 | return If(hk, Not(HasOp(op))) 146 | } 147 | 148 | // FixedError is a hook returning a fixed error. 149 | func FixedError(err error) models.Hook { 150 | return func(models.Mutator) models.Mutator { 151 | return models.MutateFunc(func(context.Context, models.Mutation) (models.Value, error) { 152 | return nil, err 153 | }) 154 | } 155 | } 156 | 157 | // Reject returns a hook that rejects all operations that match op. 158 | // 159 | // func (T) Hooks() []models.Hook { 160 | // return []models.Hook{ 161 | // Reject(models.Delete|models.Update), 162 | // } 163 | // } 164 | // 165 | func Reject(op models.Op) models.Hook { 166 | hk := FixedError(fmt.Errorf("%s operation is not allowed", op)) 167 | return On(hk, op) 168 | } 169 | 170 | // Chain acts as a list of hooks and is effectively immutable. 171 | // Once created, it will always hold the same set of hooks in the same order. 172 | type Chain struct { 173 | hooks []models.Hook 174 | } 175 | 176 | // NewChain creates a new chain of hooks. 177 | func NewChain(hooks ...models.Hook) Chain { 178 | return Chain{append([]models.Hook(nil), hooks...)} 179 | } 180 | 181 | // Hook chains the list of hooks and returns the final hook. 182 | func (c Chain) Hook() models.Hook { 183 | return func(mutator models.Mutator) models.Mutator { 184 | for i := len(c.hooks) - 1; i >= 0; i-- { 185 | mutator = c.hooks[i](mutator) 186 | } 187 | return mutator 188 | } 189 | } 190 | 191 | // Append extends a chain, adding the specified hook 192 | // as the last ones in the mutation flow. 193 | func (c Chain) Append(hooks ...models.Hook) Chain { 194 | newHooks := make([]models.Hook, 0, len(c.hooks)+len(hooks)) 195 | newHooks = append(newHooks, c.hooks...) 196 | newHooks = append(newHooks, hooks...) 197 | return Chain{newHooks} 198 | } 199 | 200 | // Extend extends a chain, adding the specified chain 201 | // as the last ones in the mutation flow. 202 | func (c Chain) Extend(chain Chain) Chain { 203 | return c.Append(chain.hooks...) 204 | } 205 | -------------------------------------------------------------------------------- /app/gen/models/migrate/migrate.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package migrate 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | 10 | "entgo.io/ent/dialect" 11 | "entgo.io/ent/dialect/sql/schema" 12 | ) 13 | 14 | var ( 15 | // WithGlobalUniqueID sets the universal ids options to the migration. 16 | // If this option is enabled, ent migration will allocate a 1<<32 range 17 | // for the ids of each entity (table). 18 | // Note that this option cannot be applied on tables that already exist. 19 | WithGlobalUniqueID = schema.WithGlobalUniqueID 20 | // WithDropColumn sets the drop column option to the migration. 21 | // If this option is enabled, ent migration will drop old columns 22 | // that were used for both fields and edges. This defaults to false. 23 | WithDropColumn = schema.WithDropColumn 24 | // WithDropIndex sets the drop index option to the migration. 25 | // If this option is enabled, ent migration will drop old indexes 26 | // that were defined in the schema. This defaults to false. 27 | // Note that unique constraints are defined using `UNIQUE INDEX`, 28 | // and therefore, it's recommended to enable this option to get more 29 | // flexibility in the schema changes. 30 | WithDropIndex = schema.WithDropIndex 31 | // WithFixture sets the foreign-key renaming option to the migration when upgrading 32 | // ent from v0.1.0 (issue-#285). Defaults to false. 33 | WithFixture = schema.WithFixture 34 | // WithForeignKeys enables creating foreign-key in schema DDL. This defaults to true. 35 | WithForeignKeys = schema.WithForeignKeys 36 | ) 37 | 38 | // Schema is the API for creating, migrating and dropping a schema. 39 | type Schema struct { 40 | drv dialect.Driver 41 | universalID bool 42 | } 43 | 44 | // NewSchema creates a new schema client. 45 | func NewSchema(drv dialect.Driver) *Schema { return &Schema{drv: drv} } 46 | 47 | // Create creates all schema resources. 48 | func (s *Schema) Create(ctx context.Context, opts ...schema.MigrateOption) error { 49 | migrate, err := schema.NewMigrate(s.drv, opts...) 50 | if err != nil { 51 | return fmt.Errorf("ent/migrate: %w", err) 52 | } 53 | return migrate.Create(ctx, Tables...) 54 | } 55 | 56 | // WriteTo writes the schema changes to w instead of running them against the database. 57 | // 58 | // if err := client.Schema.WriteTo(context.Background(), os.Stdout); err != nil { 59 | // log.Fatal(err) 60 | // } 61 | // 62 | func (s *Schema) WriteTo(ctx context.Context, w io.Writer, opts ...schema.MigrateOption) error { 63 | drv := &schema.WriteDriver{ 64 | Writer: w, 65 | Driver: s.drv, 66 | } 67 | migrate, err := schema.NewMigrate(drv, opts...) 68 | if err != nil { 69 | return fmt.Errorf("ent/migrate: %w", err) 70 | } 71 | return migrate.Create(ctx, Tables...) 72 | } 73 | -------------------------------------------------------------------------------- /app/gen/models/migrate/schema.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package migrate 4 | 5 | import ( 6 | "entgo.io/ent/dialect/entsql" 7 | "entgo.io/ent/dialect/sql/schema" 8 | "entgo.io/ent/schema/field" 9 | ) 10 | 11 | var ( 12 | // TasksColumns holds the columns for the "tasks" table. 13 | TasksColumns = []*schema.Column{ 14 | {Name: "id", Type: field.TypeString}, 15 | {Name: "owner", Type: field.TypeString}, 16 | {Name: "text", Type: field.TypeString, Size: 2147483647}, 17 | {Name: "status", Type: field.TypeEnum, Nullable: true, Enums: []string{"todo", "inprogress", "done"}, Default: "todo"}, 18 | {Name: "created_at", Type: field.TypeTime}, 19 | {Name: "updated_at", Type: field.TypeTime}, 20 | } 21 | // TasksTable holds the schema information for the "tasks" table. 22 | TasksTable = &schema.Table{ 23 | Name: "tasks", 24 | Columns: TasksColumns, 25 | PrimaryKey: []*schema.Column{TasksColumns[0]}, 26 | ForeignKeys: []*schema.ForeignKey{}, 27 | } 28 | // Tables holds all the tables in the schema. 29 | Tables = []*schema.Table{ 30 | TasksTable, 31 | } 32 | ) 33 | 34 | func init() { 35 | TasksTable.Annotation = &entsql.Annotation{ 36 | Table: "tasks", 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/gen/models/predicate/predicate.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package predicate 4 | 5 | import ( 6 | "entgo.io/ent/dialect/sql" 7 | ) 8 | 9 | // Task is the predicate function for task builders. 10 | type Task func(*sql.Selector) 11 | -------------------------------------------------------------------------------- /app/gen/models/runtime.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "time" 7 | 8 | "github.com/adnaan/gomodest-starter/app/gen/models/task" 9 | "github.com/adnaan/gomodest-starter/app/schema" 10 | ) 11 | 12 | // The init function reads all schema descriptors with runtime code 13 | // (default values, validators, hooks and policies) and stitches it 14 | // to their package variables. 15 | func init() { 16 | taskFields := schema.Task{}.Fields() 17 | _ = taskFields 18 | // taskDescCreatedAt is the schema descriptor for created_at field. 19 | taskDescCreatedAt := taskFields[4].Descriptor() 20 | // task.DefaultCreatedAt holds the default value on creation for the created_at field. 21 | task.DefaultCreatedAt = taskDescCreatedAt.Default.(func() time.Time) 22 | // taskDescUpdatedAt is the schema descriptor for updated_at field. 23 | taskDescUpdatedAt := taskFields[5].Descriptor() 24 | // task.DefaultUpdatedAt holds the default value on creation for the updated_at field. 25 | task.DefaultUpdatedAt = taskDescUpdatedAt.Default.(func() time.Time) 26 | // task.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. 27 | task.UpdateDefaultUpdatedAt = taskDescUpdatedAt.UpdateDefault.(func() time.Time) 28 | } 29 | -------------------------------------------------------------------------------- /app/gen/models/runtime/runtime.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package runtime 4 | 5 | // The schema-stitching logic is generated in github.com/adnaan/gomodest-starter/app/gen/models/runtime.go 6 | 7 | const ( 8 | Version = "(devel)" // Version of ent codegen. 9 | ) 10 | -------------------------------------------------------------------------------- /app/gen/models/task.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "entgo.io/ent/dialect/sql" 11 | "github.com/adnaan/gomodest-starter/app/gen/models/task" 12 | ) 13 | 14 | // Task is the model entity for the Task schema. 15 | type Task struct { 16 | config `json:"-"` 17 | // ID of the ent. 18 | ID string `json:"id,omitempty"` 19 | // Owner holds the value of the "owner" field. 20 | Owner string `json:"owner,omitempty"` 21 | // Text holds the value of the "text" field. 22 | Text string `json:"text,omitempty"` 23 | // Status holds the value of the "status" field. 24 | Status task.Status `json:"status,omitempty"` 25 | // CreatedAt holds the value of the "created_at" field. 26 | CreatedAt time.Time `json:"created_at,omitempty"` 27 | // UpdatedAt holds the value of the "updated_at" field. 28 | UpdatedAt time.Time `json:"updated_at,omitempty"` 29 | } 30 | 31 | // scanValues returns the types for scanning values from sql.Rows. 32 | func (*Task) scanValues(columns []string) ([]interface{}, error) { 33 | values := make([]interface{}, len(columns)) 34 | for i := range columns { 35 | switch columns[i] { 36 | case task.FieldID, task.FieldOwner, task.FieldText, task.FieldStatus: 37 | values[i] = &sql.NullString{} 38 | case task.FieldCreatedAt, task.FieldUpdatedAt: 39 | values[i] = &sql.NullTime{} 40 | default: 41 | return nil, fmt.Errorf("unexpected column %q for type Task", columns[i]) 42 | } 43 | } 44 | return values, nil 45 | } 46 | 47 | // assignValues assigns the values that were returned from sql.Rows (after scanning) 48 | // to the Task fields. 49 | func (t *Task) assignValues(columns []string, values []interface{}) error { 50 | if m, n := len(values), len(columns); m < n { 51 | return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) 52 | } 53 | for i := range columns { 54 | switch columns[i] { 55 | case task.FieldID: 56 | if value, ok := values[i].(*sql.NullString); !ok { 57 | return fmt.Errorf("unexpected type %T for field id", values[i]) 58 | } else if value.Valid { 59 | t.ID = value.String 60 | } 61 | case task.FieldOwner: 62 | if value, ok := values[i].(*sql.NullString); !ok { 63 | return fmt.Errorf("unexpected type %T for field owner", values[i]) 64 | } else if value.Valid { 65 | t.Owner = value.String 66 | } 67 | case task.FieldText: 68 | if value, ok := values[i].(*sql.NullString); !ok { 69 | return fmt.Errorf("unexpected type %T for field text", values[i]) 70 | } else if value.Valid { 71 | t.Text = value.String 72 | } 73 | case task.FieldStatus: 74 | if value, ok := values[i].(*sql.NullString); !ok { 75 | return fmt.Errorf("unexpected type %T for field status", values[i]) 76 | } else if value.Valid { 77 | t.Status = task.Status(value.String) 78 | } 79 | case task.FieldCreatedAt: 80 | if value, ok := values[i].(*sql.NullTime); !ok { 81 | return fmt.Errorf("unexpected type %T for field created_at", values[i]) 82 | } else if value.Valid { 83 | t.CreatedAt = value.Time 84 | } 85 | case task.FieldUpdatedAt: 86 | if value, ok := values[i].(*sql.NullTime); !ok { 87 | return fmt.Errorf("unexpected type %T for field updated_at", values[i]) 88 | } else if value.Valid { 89 | t.UpdatedAt = value.Time 90 | } 91 | } 92 | } 93 | return nil 94 | } 95 | 96 | // Update returns a builder for updating this Task. 97 | // Note that you need to call Task.Unwrap() before calling this method if this Task 98 | // was returned from a transaction, and the transaction was committed or rolled back. 99 | func (t *Task) Update() *TaskUpdateOne { 100 | return (&TaskClient{config: t.config}).UpdateOne(t) 101 | } 102 | 103 | // Unwrap unwraps the Task entity that was returned from a transaction after it was closed, 104 | // so that all future queries will be executed through the driver which created the transaction. 105 | func (t *Task) Unwrap() *Task { 106 | tx, ok := t.config.driver.(*txDriver) 107 | if !ok { 108 | panic("models: Task is not a transactional entity") 109 | } 110 | t.config.driver = tx.drv 111 | return t 112 | } 113 | 114 | // String implements the fmt.Stringer. 115 | func (t *Task) String() string { 116 | var builder strings.Builder 117 | builder.WriteString("Task(") 118 | builder.WriteString(fmt.Sprintf("id=%v", t.ID)) 119 | builder.WriteString(", owner=") 120 | builder.WriteString(t.Owner) 121 | builder.WriteString(", text=") 122 | builder.WriteString(t.Text) 123 | builder.WriteString(", status=") 124 | builder.WriteString(fmt.Sprintf("%v", t.Status)) 125 | builder.WriteString(", created_at=") 126 | builder.WriteString(t.CreatedAt.Format(time.ANSIC)) 127 | builder.WriteString(", updated_at=") 128 | builder.WriteString(t.UpdatedAt.Format(time.ANSIC)) 129 | builder.WriteByte(')') 130 | return builder.String() 131 | } 132 | 133 | // Tasks is a parsable slice of Task. 134 | type Tasks []*Task 135 | 136 | func (t Tasks) config(cfg config) { 137 | for _i := range t { 138 | t[_i].config = cfg 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /app/gen/models/task/task.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package task 4 | 5 | import ( 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | const ( 11 | // Label holds the string label denoting the task type in the database. 12 | Label = "task" 13 | // FieldID holds the string denoting the id field in the database. 14 | FieldID = "id" 15 | // FieldOwner holds the string denoting the owner field in the database. 16 | FieldOwner = "owner" 17 | // FieldText holds the string denoting the text field in the database. 18 | FieldText = "text" 19 | // FieldStatus holds the string denoting the status field in the database. 20 | FieldStatus = "status" 21 | // FieldCreatedAt holds the string denoting the created_at field in the database. 22 | FieldCreatedAt = "created_at" 23 | // FieldUpdatedAt holds the string denoting the updated_at field in the database. 24 | FieldUpdatedAt = "updated_at" 25 | // Table holds the table name of the task in the database. 26 | Table = "tasks" 27 | ) 28 | 29 | // Columns holds all SQL columns for task fields. 30 | var Columns = []string{ 31 | FieldID, 32 | FieldOwner, 33 | FieldText, 34 | FieldStatus, 35 | FieldCreatedAt, 36 | FieldUpdatedAt, 37 | } 38 | 39 | // ValidColumn reports if the column name is valid (part of the table columns). 40 | func ValidColumn(column string) bool { 41 | for i := range Columns { 42 | if column == Columns[i] { 43 | return true 44 | } 45 | } 46 | return false 47 | } 48 | 49 | var ( 50 | // DefaultCreatedAt holds the default value on creation for the "created_at" field. 51 | DefaultCreatedAt func() time.Time 52 | // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. 53 | DefaultUpdatedAt func() time.Time 54 | // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. 55 | UpdateDefaultUpdatedAt func() time.Time 56 | ) 57 | 58 | // Status defines the type for the "status" enum field. 59 | type Status string 60 | 61 | // StatusTodo is the default value of the Status enum. 62 | const DefaultStatus = StatusTodo 63 | 64 | // Status values. 65 | const ( 66 | StatusTodo Status = "todo" 67 | StatusInprogress Status = "inprogress" 68 | StatusDone Status = "done" 69 | ) 70 | 71 | func (s Status) String() string { 72 | return string(s) 73 | } 74 | 75 | // StatusValidator is a validator for the "status" field enum values. It is called by the builders before save. 76 | func StatusValidator(s Status) error { 77 | switch s { 78 | case StatusTodo, StatusInprogress, StatusDone: 79 | return nil 80 | default: 81 | return fmt.Errorf("task: invalid enum value for status field: %q", s) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/gen/models/task_create.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "time" 10 | 11 | "entgo.io/ent/dialect/sql/sqlgraph" 12 | "entgo.io/ent/schema/field" 13 | "github.com/adnaan/gomodest-starter/app/gen/models/task" 14 | ) 15 | 16 | // TaskCreate is the builder for creating a Task entity. 17 | type TaskCreate struct { 18 | config 19 | mutation *TaskMutation 20 | hooks []Hook 21 | } 22 | 23 | // SetOwner sets the "owner" field. 24 | func (tc *TaskCreate) SetOwner(s string) *TaskCreate { 25 | tc.mutation.SetOwner(s) 26 | return tc 27 | } 28 | 29 | // SetText sets the "text" field. 30 | func (tc *TaskCreate) SetText(s string) *TaskCreate { 31 | tc.mutation.SetText(s) 32 | return tc 33 | } 34 | 35 | // SetStatus sets the "status" field. 36 | func (tc *TaskCreate) SetStatus(t task.Status) *TaskCreate { 37 | tc.mutation.SetStatus(t) 38 | return tc 39 | } 40 | 41 | // SetNillableStatus sets the "status" field if the given value is not nil. 42 | func (tc *TaskCreate) SetNillableStatus(t *task.Status) *TaskCreate { 43 | if t != nil { 44 | tc.SetStatus(*t) 45 | } 46 | return tc 47 | } 48 | 49 | // SetCreatedAt sets the "created_at" field. 50 | func (tc *TaskCreate) SetCreatedAt(t time.Time) *TaskCreate { 51 | tc.mutation.SetCreatedAt(t) 52 | return tc 53 | } 54 | 55 | // SetNillableCreatedAt sets the "created_at" field if the given value is not nil. 56 | func (tc *TaskCreate) SetNillableCreatedAt(t *time.Time) *TaskCreate { 57 | if t != nil { 58 | tc.SetCreatedAt(*t) 59 | } 60 | return tc 61 | } 62 | 63 | // SetUpdatedAt sets the "updated_at" field. 64 | func (tc *TaskCreate) SetUpdatedAt(t time.Time) *TaskCreate { 65 | tc.mutation.SetUpdatedAt(t) 66 | return tc 67 | } 68 | 69 | // SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil. 70 | func (tc *TaskCreate) SetNillableUpdatedAt(t *time.Time) *TaskCreate { 71 | if t != nil { 72 | tc.SetUpdatedAt(*t) 73 | } 74 | return tc 75 | } 76 | 77 | // SetID sets the "id" field. 78 | func (tc *TaskCreate) SetID(s string) *TaskCreate { 79 | tc.mutation.SetID(s) 80 | return tc 81 | } 82 | 83 | // Mutation returns the TaskMutation object of the builder. 84 | func (tc *TaskCreate) Mutation() *TaskMutation { 85 | return tc.mutation 86 | } 87 | 88 | // Save creates the Task in the database. 89 | func (tc *TaskCreate) Save(ctx context.Context) (*Task, error) { 90 | var ( 91 | err error 92 | node *Task 93 | ) 94 | tc.defaults() 95 | if len(tc.hooks) == 0 { 96 | if err = tc.check(); err != nil { 97 | return nil, err 98 | } 99 | node, err = tc.sqlSave(ctx) 100 | } else { 101 | var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { 102 | mutation, ok := m.(*TaskMutation) 103 | if !ok { 104 | return nil, fmt.Errorf("unexpected mutation type %T", m) 105 | } 106 | if err = tc.check(); err != nil { 107 | return nil, err 108 | } 109 | tc.mutation = mutation 110 | node, err = tc.sqlSave(ctx) 111 | mutation.done = true 112 | return node, err 113 | }) 114 | for i := len(tc.hooks) - 1; i >= 0; i-- { 115 | mut = tc.hooks[i](mut) 116 | } 117 | if _, err := mut.Mutate(ctx, tc.mutation); err != nil { 118 | return nil, err 119 | } 120 | } 121 | return node, err 122 | } 123 | 124 | // SaveX calls Save and panics if Save returns an error. 125 | func (tc *TaskCreate) SaveX(ctx context.Context) *Task { 126 | v, err := tc.Save(ctx) 127 | if err != nil { 128 | panic(err) 129 | } 130 | return v 131 | } 132 | 133 | // defaults sets the default values of the builder before save. 134 | func (tc *TaskCreate) defaults() { 135 | if _, ok := tc.mutation.Status(); !ok { 136 | v := task.DefaultStatus 137 | tc.mutation.SetStatus(v) 138 | } 139 | if _, ok := tc.mutation.CreatedAt(); !ok { 140 | v := task.DefaultCreatedAt() 141 | tc.mutation.SetCreatedAt(v) 142 | } 143 | if _, ok := tc.mutation.UpdatedAt(); !ok { 144 | v := task.DefaultUpdatedAt() 145 | tc.mutation.SetUpdatedAt(v) 146 | } 147 | } 148 | 149 | // check runs all checks and user-defined validators on the builder. 150 | func (tc *TaskCreate) check() error { 151 | if _, ok := tc.mutation.Owner(); !ok { 152 | return &ValidationError{Name: "owner", err: errors.New("models: missing required field \"owner\"")} 153 | } 154 | if _, ok := tc.mutation.Text(); !ok { 155 | return &ValidationError{Name: "text", err: errors.New("models: missing required field \"text\"")} 156 | } 157 | if v, ok := tc.mutation.Status(); ok { 158 | if err := task.StatusValidator(v); err != nil { 159 | return &ValidationError{Name: "status", err: fmt.Errorf("models: validator failed for field \"status\": %w", err)} 160 | } 161 | } 162 | if _, ok := tc.mutation.CreatedAt(); !ok { 163 | return &ValidationError{Name: "created_at", err: errors.New("models: missing required field \"created_at\"")} 164 | } 165 | if _, ok := tc.mutation.UpdatedAt(); !ok { 166 | return &ValidationError{Name: "updated_at", err: errors.New("models: missing required field \"updated_at\"")} 167 | } 168 | return nil 169 | } 170 | 171 | func (tc *TaskCreate) sqlSave(ctx context.Context) (*Task, error) { 172 | _node, _spec := tc.createSpec() 173 | if err := sqlgraph.CreateNode(ctx, tc.driver, _spec); err != nil { 174 | if cerr, ok := isSQLConstraintError(err); ok { 175 | err = cerr 176 | } 177 | return nil, err 178 | } 179 | return _node, nil 180 | } 181 | 182 | func (tc *TaskCreate) createSpec() (*Task, *sqlgraph.CreateSpec) { 183 | var ( 184 | _node = &Task{config: tc.config} 185 | _spec = &sqlgraph.CreateSpec{ 186 | Table: task.Table, 187 | ID: &sqlgraph.FieldSpec{ 188 | Type: field.TypeString, 189 | Column: task.FieldID, 190 | }, 191 | } 192 | ) 193 | if id, ok := tc.mutation.ID(); ok { 194 | _node.ID = id 195 | _spec.ID.Value = id 196 | } 197 | if value, ok := tc.mutation.Owner(); ok { 198 | _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ 199 | Type: field.TypeString, 200 | Value: value, 201 | Column: task.FieldOwner, 202 | }) 203 | _node.Owner = value 204 | } 205 | if value, ok := tc.mutation.Text(); ok { 206 | _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ 207 | Type: field.TypeString, 208 | Value: value, 209 | Column: task.FieldText, 210 | }) 211 | _node.Text = value 212 | } 213 | if value, ok := tc.mutation.Status(); ok { 214 | _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ 215 | Type: field.TypeEnum, 216 | Value: value, 217 | Column: task.FieldStatus, 218 | }) 219 | _node.Status = value 220 | } 221 | if value, ok := tc.mutation.CreatedAt(); ok { 222 | _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ 223 | Type: field.TypeTime, 224 | Value: value, 225 | Column: task.FieldCreatedAt, 226 | }) 227 | _node.CreatedAt = value 228 | } 229 | if value, ok := tc.mutation.UpdatedAt(); ok { 230 | _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ 231 | Type: field.TypeTime, 232 | Value: value, 233 | Column: task.FieldUpdatedAt, 234 | }) 235 | _node.UpdatedAt = value 236 | } 237 | return _node, _spec 238 | } 239 | 240 | // TaskCreateBulk is the builder for creating many Task entities in bulk. 241 | type TaskCreateBulk struct { 242 | config 243 | builders []*TaskCreate 244 | } 245 | 246 | // Save creates the Task entities in the database. 247 | func (tcb *TaskCreateBulk) Save(ctx context.Context) ([]*Task, error) { 248 | specs := make([]*sqlgraph.CreateSpec, len(tcb.builders)) 249 | nodes := make([]*Task, len(tcb.builders)) 250 | mutators := make([]Mutator, len(tcb.builders)) 251 | for i := range tcb.builders { 252 | func(i int, root context.Context) { 253 | builder := tcb.builders[i] 254 | builder.defaults() 255 | var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { 256 | mutation, ok := m.(*TaskMutation) 257 | if !ok { 258 | return nil, fmt.Errorf("unexpected mutation type %T", m) 259 | } 260 | if err := builder.check(); err != nil { 261 | return nil, err 262 | } 263 | builder.mutation = mutation 264 | nodes[i], specs[i] = builder.createSpec() 265 | var err error 266 | if i < len(mutators)-1 { 267 | _, err = mutators[i+1].Mutate(root, tcb.builders[i+1].mutation) 268 | } else { 269 | // Invoke the actual operation on the latest mutation in the chain. 270 | if err = sqlgraph.BatchCreate(ctx, tcb.driver, &sqlgraph.BatchCreateSpec{Nodes: specs}); err != nil { 271 | if cerr, ok := isSQLConstraintError(err); ok { 272 | err = cerr 273 | } 274 | } 275 | } 276 | mutation.done = true 277 | if err != nil { 278 | return nil, err 279 | } 280 | return nodes[i], nil 281 | }) 282 | for i := len(builder.hooks) - 1; i >= 0; i-- { 283 | mut = builder.hooks[i](mut) 284 | } 285 | mutators[i] = mut 286 | }(i, ctx) 287 | } 288 | if len(mutators) > 0 { 289 | if _, err := mutators[0].Mutate(ctx, tcb.builders[0].mutation); err != nil { 290 | return nil, err 291 | } 292 | } 293 | return nodes, nil 294 | } 295 | 296 | // SaveX is like Save, but panics if an error occurs. 297 | func (tcb *TaskCreateBulk) SaveX(ctx context.Context) []*Task { 298 | v, err := tcb.Save(ctx) 299 | if err != nil { 300 | panic(err) 301 | } 302 | return v 303 | } 304 | -------------------------------------------------------------------------------- /app/gen/models/task_delete.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "entgo.io/ent/dialect/sql" 10 | "entgo.io/ent/dialect/sql/sqlgraph" 11 | "entgo.io/ent/schema/field" 12 | "github.com/adnaan/gomodest-starter/app/gen/models/predicate" 13 | "github.com/adnaan/gomodest-starter/app/gen/models/task" 14 | ) 15 | 16 | // TaskDelete is the builder for deleting a Task entity. 17 | type TaskDelete struct { 18 | config 19 | hooks []Hook 20 | mutation *TaskMutation 21 | } 22 | 23 | // Where adds a new predicate to the TaskDelete builder. 24 | func (td *TaskDelete) Where(ps ...predicate.Task) *TaskDelete { 25 | td.mutation.predicates = append(td.mutation.predicates, ps...) 26 | return td 27 | } 28 | 29 | // Exec executes the deletion query and returns how many vertices were deleted. 30 | func (td *TaskDelete) Exec(ctx context.Context) (int, error) { 31 | var ( 32 | err error 33 | affected int 34 | ) 35 | if len(td.hooks) == 0 { 36 | affected, err = td.sqlExec(ctx) 37 | } else { 38 | var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { 39 | mutation, ok := m.(*TaskMutation) 40 | if !ok { 41 | return nil, fmt.Errorf("unexpected mutation type %T", m) 42 | } 43 | td.mutation = mutation 44 | affected, err = td.sqlExec(ctx) 45 | mutation.done = true 46 | return affected, err 47 | }) 48 | for i := len(td.hooks) - 1; i >= 0; i-- { 49 | mut = td.hooks[i](mut) 50 | } 51 | if _, err := mut.Mutate(ctx, td.mutation); err != nil { 52 | return 0, err 53 | } 54 | } 55 | return affected, err 56 | } 57 | 58 | // ExecX is like Exec, but panics if an error occurs. 59 | func (td *TaskDelete) ExecX(ctx context.Context) int { 60 | n, err := td.Exec(ctx) 61 | if err != nil { 62 | panic(err) 63 | } 64 | return n 65 | } 66 | 67 | func (td *TaskDelete) sqlExec(ctx context.Context) (int, error) { 68 | _spec := &sqlgraph.DeleteSpec{ 69 | Node: &sqlgraph.NodeSpec{ 70 | Table: task.Table, 71 | ID: &sqlgraph.FieldSpec{ 72 | Type: field.TypeString, 73 | Column: task.FieldID, 74 | }, 75 | }, 76 | } 77 | if ps := td.mutation.predicates; len(ps) > 0 { 78 | _spec.Predicate = func(selector *sql.Selector) { 79 | for i := range ps { 80 | ps[i](selector) 81 | } 82 | } 83 | } 84 | return sqlgraph.DeleteNodes(ctx, td.driver, _spec) 85 | } 86 | 87 | // TaskDeleteOne is the builder for deleting a single Task entity. 88 | type TaskDeleteOne struct { 89 | td *TaskDelete 90 | } 91 | 92 | // Exec executes the deletion query. 93 | func (tdo *TaskDeleteOne) Exec(ctx context.Context) error { 94 | n, err := tdo.td.Exec(ctx) 95 | switch { 96 | case err != nil: 97 | return err 98 | case n == 0: 99 | return &NotFoundError{task.Label} 100 | default: 101 | return nil 102 | } 103 | } 104 | 105 | // ExecX is like Exec, but panics if an error occurs. 106 | func (tdo *TaskDeleteOne) ExecX(ctx context.Context) { 107 | tdo.td.ExecX(ctx) 108 | } 109 | -------------------------------------------------------------------------------- /app/gen/models/task_update.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "time" 9 | 10 | "entgo.io/ent/dialect/sql" 11 | "entgo.io/ent/dialect/sql/sqlgraph" 12 | "entgo.io/ent/schema/field" 13 | "github.com/adnaan/gomodest-starter/app/gen/models/predicate" 14 | "github.com/adnaan/gomodest-starter/app/gen/models/task" 15 | ) 16 | 17 | // TaskUpdate is the builder for updating Task entities. 18 | type TaskUpdate struct { 19 | config 20 | hooks []Hook 21 | mutation *TaskMutation 22 | } 23 | 24 | // Where adds a new predicate for the TaskUpdate builder. 25 | func (tu *TaskUpdate) Where(ps ...predicate.Task) *TaskUpdate { 26 | tu.mutation.predicates = append(tu.mutation.predicates, ps...) 27 | return tu 28 | } 29 | 30 | // SetOwner sets the "owner" field. 31 | func (tu *TaskUpdate) SetOwner(s string) *TaskUpdate { 32 | tu.mutation.SetOwner(s) 33 | return tu 34 | } 35 | 36 | // SetText sets the "text" field. 37 | func (tu *TaskUpdate) SetText(s string) *TaskUpdate { 38 | tu.mutation.SetText(s) 39 | return tu 40 | } 41 | 42 | // SetStatus sets the "status" field. 43 | func (tu *TaskUpdate) SetStatus(t task.Status) *TaskUpdate { 44 | tu.mutation.SetStatus(t) 45 | return tu 46 | } 47 | 48 | // SetNillableStatus sets the "status" field if the given value is not nil. 49 | func (tu *TaskUpdate) SetNillableStatus(t *task.Status) *TaskUpdate { 50 | if t != nil { 51 | tu.SetStatus(*t) 52 | } 53 | return tu 54 | } 55 | 56 | // ClearStatus clears the value of the "status" field. 57 | func (tu *TaskUpdate) ClearStatus() *TaskUpdate { 58 | tu.mutation.ClearStatus() 59 | return tu 60 | } 61 | 62 | // SetUpdatedAt sets the "updated_at" field. 63 | func (tu *TaskUpdate) SetUpdatedAt(t time.Time) *TaskUpdate { 64 | tu.mutation.SetUpdatedAt(t) 65 | return tu 66 | } 67 | 68 | // Mutation returns the TaskMutation object of the builder. 69 | func (tu *TaskUpdate) Mutation() *TaskMutation { 70 | return tu.mutation 71 | } 72 | 73 | // Save executes the query and returns the number of nodes affected by the update operation. 74 | func (tu *TaskUpdate) Save(ctx context.Context) (int, error) { 75 | var ( 76 | err error 77 | affected int 78 | ) 79 | tu.defaults() 80 | if len(tu.hooks) == 0 { 81 | if err = tu.check(); err != nil { 82 | return 0, err 83 | } 84 | affected, err = tu.sqlSave(ctx) 85 | } else { 86 | var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { 87 | mutation, ok := m.(*TaskMutation) 88 | if !ok { 89 | return nil, fmt.Errorf("unexpected mutation type %T", m) 90 | } 91 | if err = tu.check(); err != nil { 92 | return 0, err 93 | } 94 | tu.mutation = mutation 95 | affected, err = tu.sqlSave(ctx) 96 | mutation.done = true 97 | return affected, err 98 | }) 99 | for i := len(tu.hooks) - 1; i >= 0; i-- { 100 | mut = tu.hooks[i](mut) 101 | } 102 | if _, err := mut.Mutate(ctx, tu.mutation); err != nil { 103 | return 0, err 104 | } 105 | } 106 | return affected, err 107 | } 108 | 109 | // SaveX is like Save, but panics if an error occurs. 110 | func (tu *TaskUpdate) SaveX(ctx context.Context) int { 111 | affected, err := tu.Save(ctx) 112 | if err != nil { 113 | panic(err) 114 | } 115 | return affected 116 | } 117 | 118 | // Exec executes the query. 119 | func (tu *TaskUpdate) Exec(ctx context.Context) error { 120 | _, err := tu.Save(ctx) 121 | return err 122 | } 123 | 124 | // ExecX is like Exec, but panics if an error occurs. 125 | func (tu *TaskUpdate) ExecX(ctx context.Context) { 126 | if err := tu.Exec(ctx); err != nil { 127 | panic(err) 128 | } 129 | } 130 | 131 | // defaults sets the default values of the builder before save. 132 | func (tu *TaskUpdate) defaults() { 133 | if _, ok := tu.mutation.UpdatedAt(); !ok { 134 | v := task.UpdateDefaultUpdatedAt() 135 | tu.mutation.SetUpdatedAt(v) 136 | } 137 | } 138 | 139 | // check runs all checks and user-defined validators on the builder. 140 | func (tu *TaskUpdate) check() error { 141 | if v, ok := tu.mutation.Status(); ok { 142 | if err := task.StatusValidator(v); err != nil { 143 | return &ValidationError{Name: "status", err: fmt.Errorf("models: validator failed for field \"status\": %w", err)} 144 | } 145 | } 146 | return nil 147 | } 148 | 149 | func (tu *TaskUpdate) sqlSave(ctx context.Context) (n int, err error) { 150 | _spec := &sqlgraph.UpdateSpec{ 151 | Node: &sqlgraph.NodeSpec{ 152 | Table: task.Table, 153 | Columns: task.Columns, 154 | ID: &sqlgraph.FieldSpec{ 155 | Type: field.TypeString, 156 | Column: task.FieldID, 157 | }, 158 | }, 159 | } 160 | if ps := tu.mutation.predicates; len(ps) > 0 { 161 | _spec.Predicate = func(selector *sql.Selector) { 162 | for i := range ps { 163 | ps[i](selector) 164 | } 165 | } 166 | } 167 | if value, ok := tu.mutation.Owner(); ok { 168 | _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ 169 | Type: field.TypeString, 170 | Value: value, 171 | Column: task.FieldOwner, 172 | }) 173 | } 174 | if value, ok := tu.mutation.Text(); ok { 175 | _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ 176 | Type: field.TypeString, 177 | Value: value, 178 | Column: task.FieldText, 179 | }) 180 | } 181 | if value, ok := tu.mutation.Status(); ok { 182 | _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ 183 | Type: field.TypeEnum, 184 | Value: value, 185 | Column: task.FieldStatus, 186 | }) 187 | } 188 | if tu.mutation.StatusCleared() { 189 | _spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{ 190 | Type: field.TypeEnum, 191 | Column: task.FieldStatus, 192 | }) 193 | } 194 | if value, ok := tu.mutation.UpdatedAt(); ok { 195 | _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ 196 | Type: field.TypeTime, 197 | Value: value, 198 | Column: task.FieldUpdatedAt, 199 | }) 200 | } 201 | if n, err = sqlgraph.UpdateNodes(ctx, tu.driver, _spec); err != nil { 202 | if _, ok := err.(*sqlgraph.NotFoundError); ok { 203 | err = &NotFoundError{task.Label} 204 | } else if cerr, ok := isSQLConstraintError(err); ok { 205 | err = cerr 206 | } 207 | return 0, err 208 | } 209 | return n, nil 210 | } 211 | 212 | // TaskUpdateOne is the builder for updating a single Task entity. 213 | type TaskUpdateOne struct { 214 | config 215 | hooks []Hook 216 | mutation *TaskMutation 217 | } 218 | 219 | // SetOwner sets the "owner" field. 220 | func (tuo *TaskUpdateOne) SetOwner(s string) *TaskUpdateOne { 221 | tuo.mutation.SetOwner(s) 222 | return tuo 223 | } 224 | 225 | // SetText sets the "text" field. 226 | func (tuo *TaskUpdateOne) SetText(s string) *TaskUpdateOne { 227 | tuo.mutation.SetText(s) 228 | return tuo 229 | } 230 | 231 | // SetStatus sets the "status" field. 232 | func (tuo *TaskUpdateOne) SetStatus(t task.Status) *TaskUpdateOne { 233 | tuo.mutation.SetStatus(t) 234 | return tuo 235 | } 236 | 237 | // SetNillableStatus sets the "status" field if the given value is not nil. 238 | func (tuo *TaskUpdateOne) SetNillableStatus(t *task.Status) *TaskUpdateOne { 239 | if t != nil { 240 | tuo.SetStatus(*t) 241 | } 242 | return tuo 243 | } 244 | 245 | // ClearStatus clears the value of the "status" field. 246 | func (tuo *TaskUpdateOne) ClearStatus() *TaskUpdateOne { 247 | tuo.mutation.ClearStatus() 248 | return tuo 249 | } 250 | 251 | // SetUpdatedAt sets the "updated_at" field. 252 | func (tuo *TaskUpdateOne) SetUpdatedAt(t time.Time) *TaskUpdateOne { 253 | tuo.mutation.SetUpdatedAt(t) 254 | return tuo 255 | } 256 | 257 | // Mutation returns the TaskMutation object of the builder. 258 | func (tuo *TaskUpdateOne) Mutation() *TaskMutation { 259 | return tuo.mutation 260 | } 261 | 262 | // Save executes the query and returns the updated Task entity. 263 | func (tuo *TaskUpdateOne) Save(ctx context.Context) (*Task, error) { 264 | var ( 265 | err error 266 | node *Task 267 | ) 268 | tuo.defaults() 269 | if len(tuo.hooks) == 0 { 270 | if err = tuo.check(); err != nil { 271 | return nil, err 272 | } 273 | node, err = tuo.sqlSave(ctx) 274 | } else { 275 | var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { 276 | mutation, ok := m.(*TaskMutation) 277 | if !ok { 278 | return nil, fmt.Errorf("unexpected mutation type %T", m) 279 | } 280 | if err = tuo.check(); err != nil { 281 | return nil, err 282 | } 283 | tuo.mutation = mutation 284 | node, err = tuo.sqlSave(ctx) 285 | mutation.done = true 286 | return node, err 287 | }) 288 | for i := len(tuo.hooks) - 1; i >= 0; i-- { 289 | mut = tuo.hooks[i](mut) 290 | } 291 | if _, err := mut.Mutate(ctx, tuo.mutation); err != nil { 292 | return nil, err 293 | } 294 | } 295 | return node, err 296 | } 297 | 298 | // SaveX is like Save, but panics if an error occurs. 299 | func (tuo *TaskUpdateOne) SaveX(ctx context.Context) *Task { 300 | node, err := tuo.Save(ctx) 301 | if err != nil { 302 | panic(err) 303 | } 304 | return node 305 | } 306 | 307 | // Exec executes the query on the entity. 308 | func (tuo *TaskUpdateOne) Exec(ctx context.Context) error { 309 | _, err := tuo.Save(ctx) 310 | return err 311 | } 312 | 313 | // ExecX is like Exec, but panics if an error occurs. 314 | func (tuo *TaskUpdateOne) ExecX(ctx context.Context) { 315 | if err := tuo.Exec(ctx); err != nil { 316 | panic(err) 317 | } 318 | } 319 | 320 | // defaults sets the default values of the builder before save. 321 | func (tuo *TaskUpdateOne) defaults() { 322 | if _, ok := tuo.mutation.UpdatedAt(); !ok { 323 | v := task.UpdateDefaultUpdatedAt() 324 | tuo.mutation.SetUpdatedAt(v) 325 | } 326 | } 327 | 328 | // check runs all checks and user-defined validators on the builder. 329 | func (tuo *TaskUpdateOne) check() error { 330 | if v, ok := tuo.mutation.Status(); ok { 331 | if err := task.StatusValidator(v); err != nil { 332 | return &ValidationError{Name: "status", err: fmt.Errorf("models: validator failed for field \"status\": %w", err)} 333 | } 334 | } 335 | return nil 336 | } 337 | 338 | func (tuo *TaskUpdateOne) sqlSave(ctx context.Context) (_node *Task, err error) { 339 | _spec := &sqlgraph.UpdateSpec{ 340 | Node: &sqlgraph.NodeSpec{ 341 | Table: task.Table, 342 | Columns: task.Columns, 343 | ID: &sqlgraph.FieldSpec{ 344 | Type: field.TypeString, 345 | Column: task.FieldID, 346 | }, 347 | }, 348 | } 349 | id, ok := tuo.mutation.ID() 350 | if !ok { 351 | return nil, &ValidationError{Name: "ID", err: fmt.Errorf("missing Task.ID for update")} 352 | } 353 | _spec.Node.ID.Value = id 354 | if ps := tuo.mutation.predicates; len(ps) > 0 { 355 | _spec.Predicate = func(selector *sql.Selector) { 356 | for i := range ps { 357 | ps[i](selector) 358 | } 359 | } 360 | } 361 | if value, ok := tuo.mutation.Owner(); ok { 362 | _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ 363 | Type: field.TypeString, 364 | Value: value, 365 | Column: task.FieldOwner, 366 | }) 367 | } 368 | if value, ok := tuo.mutation.Text(); ok { 369 | _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ 370 | Type: field.TypeString, 371 | Value: value, 372 | Column: task.FieldText, 373 | }) 374 | } 375 | if value, ok := tuo.mutation.Status(); ok { 376 | _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ 377 | Type: field.TypeEnum, 378 | Value: value, 379 | Column: task.FieldStatus, 380 | }) 381 | } 382 | if tuo.mutation.StatusCleared() { 383 | _spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{ 384 | Type: field.TypeEnum, 385 | Column: task.FieldStatus, 386 | }) 387 | } 388 | if value, ok := tuo.mutation.UpdatedAt(); ok { 389 | _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ 390 | Type: field.TypeTime, 391 | Value: value, 392 | Column: task.FieldUpdatedAt, 393 | }) 394 | } 395 | _node = &Task{config: tuo.config} 396 | _spec.Assign = _node.assignValues 397 | _spec.ScanValues = _node.scanValues 398 | if err = sqlgraph.UpdateNode(ctx, tuo.driver, _spec); err != nil { 399 | if _, ok := err.(*sqlgraph.NotFoundError); ok { 400 | err = &NotFoundError{task.Label} 401 | } else if cerr, ok := isSQLConstraintError(err); ok { 402 | err = cerr 403 | } 404 | return nil, err 405 | } 406 | return _node, nil 407 | } 408 | -------------------------------------------------------------------------------- /app/gen/models/tx.go: -------------------------------------------------------------------------------- 1 | // Code generated (@generated) by entc, DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | "context" 7 | "sync" 8 | 9 | "entgo.io/ent/dialect" 10 | ) 11 | 12 | // Tx is a transactional client that is created by calling Client.Tx(). 13 | type Tx struct { 14 | config 15 | // Task is the client for interacting with the Task builders. 16 | Task *TaskClient 17 | 18 | // lazily loaded. 19 | client *Client 20 | clientOnce sync.Once 21 | 22 | // completion callbacks. 23 | mu sync.Mutex 24 | onCommit []CommitHook 25 | onRollback []RollbackHook 26 | 27 | // ctx lives for the life of the transaction. It is 28 | // the same context used by the underlying connection. 29 | ctx context.Context 30 | } 31 | 32 | type ( 33 | // Committer is the interface that wraps the Committer method. 34 | Committer interface { 35 | Commit(context.Context, *Tx) error 36 | } 37 | 38 | // The CommitFunc type is an adapter to allow the use of ordinary 39 | // function as a Committer. If f is a function with the appropriate 40 | // signature, CommitFunc(f) is a Committer that calls f. 41 | CommitFunc func(context.Context, *Tx) error 42 | 43 | // CommitHook defines the "commit middleware". A function that gets a Committer 44 | // and returns a Committer. For example: 45 | // 46 | // hook := func(next ent.Committer) ent.Committer { 47 | // return ent.CommitFunc(func(context.Context, tx *ent.Tx) error { 48 | // // Do some stuff before. 49 | // if err := next.Commit(ctx, tx); err != nil { 50 | // return err 51 | // } 52 | // // Do some stuff after. 53 | // return nil 54 | // }) 55 | // } 56 | // 57 | CommitHook func(Committer) Committer 58 | ) 59 | 60 | // Commit calls f(ctx, m). 61 | func (f CommitFunc) Commit(ctx context.Context, tx *Tx) error { 62 | return f(ctx, tx) 63 | } 64 | 65 | // Commit commits the transaction. 66 | func (tx *Tx) Commit() error { 67 | txDriver := tx.config.driver.(*txDriver) 68 | var fn Committer = CommitFunc(func(context.Context, *Tx) error { 69 | return txDriver.tx.Commit() 70 | }) 71 | tx.mu.Lock() 72 | hooks := append([]CommitHook(nil), tx.onCommit...) 73 | tx.mu.Unlock() 74 | for i := len(hooks) - 1; i >= 0; i-- { 75 | fn = hooks[i](fn) 76 | } 77 | return fn.Commit(tx.ctx, tx) 78 | } 79 | 80 | // OnCommit adds a hook to call on commit. 81 | func (tx *Tx) OnCommit(f CommitHook) { 82 | tx.mu.Lock() 83 | defer tx.mu.Unlock() 84 | tx.onCommit = append(tx.onCommit, f) 85 | } 86 | 87 | type ( 88 | // Rollbacker is the interface that wraps the Rollbacker method. 89 | Rollbacker interface { 90 | Rollback(context.Context, *Tx) error 91 | } 92 | 93 | // The RollbackFunc type is an adapter to allow the use of ordinary 94 | // function as a Rollbacker. If f is a function with the appropriate 95 | // signature, RollbackFunc(f) is a Rollbacker that calls f. 96 | RollbackFunc func(context.Context, *Tx) error 97 | 98 | // RollbackHook defines the "rollback middleware". A function that gets a Rollbacker 99 | // and returns a Rollbacker. For example: 100 | // 101 | // hook := func(next ent.Rollbacker) ent.Rollbacker { 102 | // return ent.RollbackFunc(func(context.Context, tx *ent.Tx) error { 103 | // // Do some stuff before. 104 | // if err := next.Rollback(ctx, tx); err != nil { 105 | // return err 106 | // } 107 | // // Do some stuff after. 108 | // return nil 109 | // }) 110 | // } 111 | // 112 | RollbackHook func(Rollbacker) Rollbacker 113 | ) 114 | 115 | // Rollback calls f(ctx, m). 116 | func (f RollbackFunc) Rollback(ctx context.Context, tx *Tx) error { 117 | return f(ctx, tx) 118 | } 119 | 120 | // Rollback rollbacks the transaction. 121 | func (tx *Tx) Rollback() error { 122 | txDriver := tx.config.driver.(*txDriver) 123 | var fn Rollbacker = RollbackFunc(func(context.Context, *Tx) error { 124 | return txDriver.tx.Rollback() 125 | }) 126 | tx.mu.Lock() 127 | hooks := append([]RollbackHook(nil), tx.onRollback...) 128 | tx.mu.Unlock() 129 | for i := len(hooks) - 1; i >= 0; i-- { 130 | fn = hooks[i](fn) 131 | } 132 | return fn.Rollback(tx.ctx, tx) 133 | } 134 | 135 | // OnRollback adds a hook to call on rollback. 136 | func (tx *Tx) OnRollback(f RollbackHook) { 137 | tx.mu.Lock() 138 | defer tx.mu.Unlock() 139 | tx.onRollback = append(tx.onRollback, f) 140 | } 141 | 142 | // Client returns a Client that binds to current transaction. 143 | func (tx *Tx) Client() *Client { 144 | tx.clientOnce.Do(func() { 145 | tx.client = &Client{config: tx.config} 146 | tx.client.init() 147 | }) 148 | return tx.client 149 | } 150 | 151 | func (tx *Tx) init() { 152 | tx.Task = NewTaskClient(tx.config) 153 | } 154 | 155 | // txDriver wraps the given dialect.Tx with a nop dialect.Driver implementation. 156 | // The idea is to support transactions without adding any extra code to the builders. 157 | // When a builder calls to driver.Tx(), it gets the same dialect.Tx instance. 158 | // Commit and Rollback are nop for the internal builders and the user must call one 159 | // of them in order to commit or rollback the transaction. 160 | // 161 | // If a closed transaction is embedded in one of the generated entities, and the entity 162 | // applies a query, for example: Task.QueryXXX(), the query will be executed 163 | // through the driver which created this transaction. 164 | // 165 | // Note that txDriver is not goroutine safe. 166 | type txDriver struct { 167 | // the driver we started the transaction from. 168 | drv dialect.Driver 169 | // tx is the underlying transaction. 170 | tx dialect.Tx 171 | } 172 | 173 | // newTx creates a new transactional driver. 174 | func newTx(ctx context.Context, drv dialect.Driver) (*txDriver, error) { 175 | tx, err := drv.Tx(ctx) 176 | if err != nil { 177 | return nil, err 178 | } 179 | return &txDriver{tx: tx, drv: drv}, nil 180 | } 181 | 182 | // Tx returns the transaction wrapper (txDriver) to avoid Commit or Rollback calls 183 | // from the internal builders. Should be called only by the internal builders. 184 | func (tx *txDriver) Tx(context.Context) (dialect.Tx, error) { return tx, nil } 185 | 186 | // Dialect returns the dialect of the driver we started the transaction from. 187 | func (tx *txDriver) Dialect() string { return tx.drv.Dialect() } 188 | 189 | // Close is a nop close. 190 | func (*txDriver) Close() error { return nil } 191 | 192 | // Commit is a nop commit for the internal builders. 193 | // User must call `Tx.Commit` in order to commit the transaction. 194 | func (*txDriver) Commit() error { return nil } 195 | 196 | // Rollback is a nop rollback for the internal builders. 197 | // User must call `Tx.Rollback` in order to rollback the transaction. 198 | func (*txDriver) Rollback() error { return nil } 199 | 200 | // Exec calls tx.Exec. 201 | func (tx *txDriver) Exec(ctx context.Context, query string, args, v interface{}) error { 202 | return tx.tx.Exec(ctx, query, args, v) 203 | } 204 | 205 | // Query calls tx.Query. 206 | func (tx *txDriver) Query(ctx context.Context, query string, args, v interface{}) error { 207 | return tx.tx.Query(ctx, query, args, v) 208 | } 209 | 210 | var _ dialect.Driver = (*txDriver)(nil) 211 | -------------------------------------------------------------------------------- /app/generator/entc.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "log" 7 | 8 | "entgo.io/ent/entc" 9 | "entgo.io/ent/entc/gen" 10 | "entgo.io/ent/schema/field" 11 | ) 12 | 13 | func main() { 14 | err := entc.Generate("../schema", &gen.Config{ 15 | Header: ` 16 | // Code generated (@generated) by entc, DO NOT EDIT. 17 | `, 18 | IDType: &field.TypeInfo{Type: field.TypeInt}, 19 | Target: "../gen/models", 20 | Package: "github.com/adnaan/gomodest-starter/app/gen/models", 21 | }) 22 | if err != nil { 23 | log.Fatal("running ent codegen:", err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/generator/generate.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import _ "entgo.io/ent/entc" 4 | 5 | //go:generate go run entc.go 6 | -------------------------------------------------------------------------------- /app/router.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/hako/branca" 10 | 11 | "github.com/adnaan/authn" 12 | 13 | "github.com/adnaan/gomodest-starter/app/gen/models" 14 | 15 | "github.com/go-playground/form" 16 | 17 | "github.com/stripe/stripe-go/v72" 18 | 19 | "github.com/markbates/goth" 20 | "github.com/markbates/goth/providers/google" 21 | 22 | "github.com/go-chi/httplog" 23 | 24 | rl "github.com/adnaan/renderlayout" 25 | "github.com/go-chi/chi" 26 | "github.com/go-chi/chi/middleware" 27 | _ "github.com/mattn/go-sqlite3" 28 | ) 29 | 30 | type Context struct { 31 | authn *authn.API 32 | cfg Config 33 | formDecoder *form.Decoder 34 | db *models.Client 35 | branca *branca.Branca 36 | } 37 | 38 | type APIRoute struct { 39 | Method string 40 | Pattern string 41 | HandlerFunc http.HandlerFunc 42 | } 43 | 44 | func Router(ctx context.Context, cfg Config) chi.Router { 45 | //driver := "postgres" 46 | //dataSource := "host=0.0.0.0 port=5432 user=gomodest-starter dbname=gomodest-starter sslmode=disable" 47 | stripe.Key = cfg.StripeSecretKey 48 | 49 | db, err := models.Open(cfg.Driver, cfg.DataSource) 50 | if err != nil { 51 | panic(err) 52 | } 53 | if err := db.Schema.Create(ctx); err != nil { 54 | panic(err) 55 | } 56 | 57 | appCtx := Context{ 58 | db: db, 59 | cfg: cfg, 60 | formDecoder: form.NewDecoder(), 61 | branca: branca.NewBranca(cfg.APIMasterSecret), 62 | } 63 | 64 | authnConfig := authn.Config{ 65 | Driver: cfg.Driver, 66 | Datasource: cfg.DataSource, 67 | SessionSecret: cfg.SessionSecret, 68 | SendMail: sendEmailFunc(cfg), 69 | GothProviders: []goth.Provider{ 70 | google.New( 71 | cfg.GoogleClientID, 72 | cfg.GoogleSecret, 73 | fmt.Sprintf("%s/auth/callback?provider=google", cfg.Domain), 74 | "email", "profile", 75 | ), 76 | }, 77 | } 78 | 79 | appCtx.authn = authn.New(ctx, authnConfig) 80 | 81 | // logger 82 | logger := httplog.NewLogger(cfg.Name, 83 | httplog.Options{ 84 | JSON: cfg.LogFormatJSON, 85 | LogLevel: cfg.LogLevel, 86 | }) 87 | 88 | index, err := rl.New( 89 | rl.Layout("index"), 90 | rl.DisableCache(true), 91 | rl.Debug(false), 92 | rl.DefaultData(defaultPageHandler(appCtx)), 93 | ) 94 | 95 | if err != nil { 96 | log.Fatal(err) 97 | } 98 | 99 | // middlewares 100 | r := chi.NewRouter() 101 | r.Use(middleware.Compress(5)) 102 | r.Use(middleware.Heartbeat(cfg.HealthPath)) 103 | r.Use(middleware.Recoverer) 104 | r.Use(httplog.RequestLogger(logger)) 105 | r.NotFound(index("404")) 106 | // public 107 | r.Route("/", func(r chi.Router) { 108 | r.Post("/webhook/{source}", handleWebhook(appCtx)) 109 | r.Get("/", index("home")) 110 | r.Get("/signup", index("account/signup")) 111 | r.Post("/signup", index("account/signup", signupPage(appCtx))) 112 | r.Get("/confirm/{token}", index("account/confirmed", confirmEmailPage(appCtx))) 113 | r.Get("/login", index("account/login", loginPage(appCtx))) 114 | r.Post("/login", index("account/login", loginPageSubmit(appCtx))) 115 | r.Get("/auth/callback", index("account/login", loginProviderCallbackPage(appCtx))) 116 | r.Get("/auth", index("account/login", loginProviderPage(appCtx))) 117 | r.Get("/magic-link-sent", index("account/magic")) 118 | r.Get("/magic-login/{otp}", index("account/login", magicLinkLoginConfirm(appCtx))) 119 | r.Get("/forgot", index("account/forgot")) 120 | r.Post("/forgot", index("account/forgot", forgotPage(appCtx))) 121 | r.Get("/reset/{token}", index("account/reset")) 122 | r.Post("/reset/{token}", index("account/reset", resetPage(appCtx))) 123 | r.Get("/logout", func(w http.ResponseWriter, r *http.Request) { 124 | acc, err := appCtx.authn.CurrentAccount(r) 125 | if err != nil { 126 | log.Println("err logging out ", err) 127 | http.Redirect(w, r, "/", http.StatusSeeOther) 128 | } 129 | acc.Logout(w, r) 130 | }) 131 | r.Get("/change/{token}", index("account/changed", confirmEmailChangePage(appCtx))) 132 | }) 133 | 134 | // authenticated 135 | r.Route("/account", func(r chi.Router) { 136 | r.Use(appCtx.authn.IsAuthenticated) 137 | r.Get("/", index("account/main", accountPage(appCtx))) 138 | r.Post("/", index("account/main", accountPageSubmit(appCtx))) 139 | r.Post("/delete", index("account/main", deleteAccount(appCtx))) 140 | 141 | r.Post("/checkout", handleCreateCheckoutSession(appCtx)) 142 | r.Get("/checkout/success", handleCheckoutSuccess(appCtx)) 143 | r.Get("/checkout/cancel", handleCheckoutCancel(appCtx)) 144 | r.Get("/subscription/manage", handleManageSubscription(appCtx)) 145 | }) 146 | 147 | r.Route("/app", func(r chi.Router) { 148 | r.Use(appCtx.authn.IsAuthenticated) 149 | r.Get("/", index("app", listTasks(appCtx))) 150 | r.Post("/tasks/new", index("app", createNewTask(appCtx), listTasks(appCtx))) 151 | r.Post("/tasks/{id}/edit", index("app", editTask(appCtx), listTasks(appCtx))) 152 | r.Post("/tasks/{id}/delete", index("app", deleteTask(appCtx), listTasks(appCtx))) 153 | }) 154 | 155 | r.Route("/api", func(r chi.Router) { 156 | r.Use(appCtx.authn.IsAuthenticated) 157 | r.Use(middleware.AllowContentType("application/json")) 158 | r.Route("/tasks", func(r chi.Router) { 159 | r.Get("/", list(appCtx)) 160 | r.Post("/", create(appCtx)) 161 | }) 162 | r.Route("/tasks/{id}", func(r chi.Router) { 163 | r.Put("/status", updateStatus(appCtx)) 164 | r.Put("/text", updateText(appCtx)) 165 | r.Delete("/", delete(appCtx)) 166 | }) 167 | }) 168 | 169 | return r 170 | } 171 | -------------------------------------------------------------------------------- /app/schema/task.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "entgo.io/ent/dialect/entsql" 7 | "entgo.io/ent/schema" 8 | 9 | "entgo.io/ent" 10 | "entgo.io/ent/schema/field" 11 | ) 12 | 13 | // Task holds the schema definition for the Task entity. 14 | type Task struct { 15 | ent.Schema 16 | } 17 | 18 | func (Task) Annotations() []schema.Annotation { 19 | return []schema.Annotation{ 20 | entsql.Annotation{Table: "tasks"}, 21 | } 22 | } 23 | 24 | // Fields of the Task. 25 | func (Task) Fields() []ent.Field { 26 | return []ent.Field{ 27 | field.String("id"), 28 | field.String("owner"), 29 | field.Text("text"), 30 | field.Enum("status").Values("todo", "inprogress", "done").Default("todo").Optional(), 31 | field.Time("created_at").Immutable().Default(time.Now), 32 | field.Time("updated_at").Default(time.Now).UpdateDefault(time.Now), 33 | } 34 | } 35 | 36 | // Edges of the Task. 37 | func (Task) Edges() []ent.Edge { 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /app/stripe.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/stripe/stripe-go/v72" 9 | portalsession "github.com/stripe/stripe-go/v72/billingportal/session" 10 | "github.com/stripe/stripe-go/v72/checkout/session" 11 | 12 | "github.com/go-chi/render" 13 | ) 14 | 15 | // modified from https://github.com/stripe-samples/checkout-single-subscription/blob/master/server/go/server.go 16 | 17 | const ( 18 | billingIDKey = "billing_id" 19 | currentPriceIDKey = "current_price_id" 20 | ) 21 | 22 | type errResponse struct { 23 | Error string `json:"error"` 24 | } 25 | 26 | func handleCreateCheckoutSession(appCtx Context) http.HandlerFunc { 27 | 28 | type req struct { 29 | Price string `json:"price"` 30 | } 31 | return func(w http.ResponseWriter, r *http.Request) { 32 | req := new(req) 33 | account, err := appCtx.authn.CurrentAccount(r) 34 | if err != nil { 35 | log.Printf("authn.CurrentAccount: %v", err) 36 | render.Status(r, http.StatusUnauthorized) 37 | render.JSON(w, r, &errResponse{"unauthorized"}) 38 | return 39 | } 40 | 41 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 42 | log.Printf("json.NewDecoder.Decode: %v", err) 43 | render.Status(r, http.StatusBadRequest) 44 | render.JSON(w, r, &errResponse{err.Error()}) 45 | return 46 | } 47 | 48 | params := &stripe.CheckoutSessionParams{ 49 | CustomerEmail: stripe.String(account.Email()), 50 | SuccessURL: stripe.String(appCtx.cfg.Domain + "/account/checkout/success?session_id={CHECKOUT_SESSION_ID}"), 51 | CancelURL: stripe.String(appCtx.cfg.Domain + "/account/checkout/cancel"), 52 | PaymentMethodTypes: stripe.StringSlice([]string{ 53 | "card", 54 | }), 55 | Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), 56 | LineItems: []*stripe.CheckoutSessionLineItemParams{ 57 | { 58 | Price: stripe.String(req.Price), 59 | Quantity: stripe.Int64(1), 60 | }, 61 | }, 62 | } 63 | 64 | s, err := session.New(params) 65 | if err != nil { 66 | log.Printf("session.New: %v", err) 67 | render.Status(r, http.StatusInternalServerError) 68 | render.JSON(w, r, &errResponse{err.Error()}) 69 | return 70 | } 71 | 72 | render.JSON(w, r, struct { 73 | SessionID string `json:"sessionId"` 74 | }{SessionID: s.ID}) 75 | 76 | } 77 | } 78 | 79 | func handleCheckoutSuccess(appCtx Context) http.HandlerFunc { 80 | return func(w http.ResponseWriter, r *http.Request) { 81 | sessionID := r.URL.Query().Get("session_id") 82 | if sessionID == "" { 83 | http.Redirect(w, r, "/account", http.StatusSeeOther) 84 | return 85 | } 86 | s, err := session.Get(sessionID, nil) 87 | if err != nil { 88 | log.Printf("session.Get: %v\n", err) 89 | render.Status(r, http.StatusBadRequest) 90 | render.JSON(w, r, &errResponse{err.Error()}) 91 | return 92 | } 93 | 94 | account, err := appCtx.authn.CurrentAccount(r) 95 | if err != nil { 96 | log.Printf("authn.CurrentAccount: %v\n", err) 97 | render.Status(r, http.StatusUnauthorized) 98 | render.JSON(w, r, &errResponse{"unauthorized"}) 99 | return 100 | } 101 | 102 | err = account.Attributes().Set(billingIDKey, s.Customer.ID) 103 | if err != nil { 104 | log.Printf("UpdateBillingID %v\n", err) 105 | render.Status(r, http.StatusInternalServerError) 106 | render.JSON(w, r, &errResponse{err.Error()}) 107 | return 108 | } 109 | 110 | http.Redirect(w, r, "/account?checkout=success", http.StatusSeeOther) 111 | } 112 | } 113 | 114 | func handleCheckoutCancel(appCtx Context) http.HandlerFunc { 115 | return func(w http.ResponseWriter, r *http.Request) { 116 | http.Redirect(w, r, "/account?checkout=cancel", http.StatusSeeOther) 117 | } 118 | } 119 | 120 | func handleManageSubscription(appCtx Context) http.HandlerFunc { 121 | return func(w http.ResponseWriter, r *http.Request) { 122 | account, err := appCtx.authn.CurrentAccount(r) 123 | if err != nil { 124 | log.Printf("authn.CurrentAccount: %v", err) 125 | render.Status(r, http.StatusUnauthorized) 126 | render.JSON(w, r, &errResponse{"unauthorized"}) 127 | return 128 | } 129 | 130 | // expect plan to be change 131 | err = account.Attributes().Session().Del(w, currentPriceIDKey) 132 | if err != nil { 133 | log.Printf("account.Attributes().Session().Del(), %s failed err %v\n", currentPriceIDKey, err) 134 | } 135 | billingID, ok := account.Attributes().Map().String(billingIDKey) 136 | if !ok { 137 | log.Printf(" %s not found \n", billingIDKey) 138 | http.Redirect(w, r, "/account", http.StatusSeeOther) 139 | return 140 | } 141 | params := &stripe.BillingPortalSessionParams{ 142 | Customer: stripe.String(billingID), 143 | ReturnURL: stripe.String(appCtx.cfg.Domain + "/account"), 144 | } 145 | 146 | ps, err := portalsession.New(params) 147 | if err != nil { 148 | log.Printf("portalsession.New: %v", err) 149 | http.Redirect(w, r, "/account", http.StatusSeeOther) 150 | return 151 | } 152 | 153 | http.Redirect(w, r, ps.URL, http.StatusSeeOther) 154 | 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /app/task_pages.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/adnaan/authn" 8 | "github.com/adnaan/gomodest-starter/app/gen/models/task" 9 | rl "github.com/adnaan/renderlayout" 10 | "github.com/go-chi/chi" 11 | "github.com/lithammer/shortuuid/v3" 12 | ) 13 | 14 | func listTasks(appCtx Context) rl.Data { 15 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 16 | userID := authn.AccountIDFromContext(r) 17 | tasks, err := appCtx.db.Task.Query().Where(task.Owner(userID)).All(r.Context()) 18 | if err != nil { 19 | return nil, fmt.Errorf("%w", err) 20 | } 21 | 22 | return rl.D{ 23 | "tasks": tasks, 24 | }, nil 25 | } 26 | } 27 | 28 | func createNewTask(appCtx Context) rl.Data { 29 | type req struct { 30 | Text string `json:"text"` 31 | } 32 | 33 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 34 | req := new(req) 35 | err := r.ParseForm() 36 | if err != nil { 37 | return nil, fmt.Errorf("%w", err) 38 | } 39 | 40 | err = appCtx.formDecoder.Decode(req, r.Form) 41 | if err != nil { 42 | return nil, fmt.Errorf("%w", err) 43 | } 44 | 45 | if req.Text == "" { 46 | return nil, fmt.Errorf("%w", fmt.Errorf("empty task")) 47 | } 48 | 49 | userID := authn.AccountIDFromContext(r) 50 | _, err = appCtx.db.Task.Create(). 51 | SetID(shortuuid.New()). 52 | SetStatus(task.StatusInprogress). 53 | SetOwner(userID). 54 | SetText(req.Text). 55 | Save(r.Context()) 56 | if err != nil { 57 | return nil, fmt.Errorf("%w", err) 58 | } 59 | 60 | return nil, nil 61 | } 62 | } 63 | 64 | func deleteTask(appCtx Context) rl.Data { 65 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 66 | id := chi.URLParam(r, "id") 67 | userID := authn.AccountIDFromContext(r) 68 | 69 | _, err := appCtx.db.Task.Delete().Where(task.And( 70 | task.Owner(userID), task.ID(id), 71 | )).Exec(r.Context()) 72 | if err != nil { 73 | return nil, fmt.Errorf("%w", err) 74 | } 75 | 76 | return nil, nil 77 | } 78 | } 79 | 80 | func editTask(appCtx Context) rl.Data { 81 | type req struct { 82 | Text string `json:"text"` 83 | } 84 | return func(w http.ResponseWriter, r *http.Request) (rl.D, error) { 85 | req := new(req) 86 | err := r.ParseForm() 87 | if err != nil { 88 | return nil, fmt.Errorf("%w", err) 89 | } 90 | 91 | err = appCtx.formDecoder.Decode(req, r.Form) 92 | if err != nil { 93 | return nil, fmt.Errorf("%w", err) 94 | } 95 | 96 | if req.Text == "" { 97 | return nil, fmt.Errorf("%w", fmt.Errorf("empty task")) 98 | } 99 | 100 | id := chi.URLParam(r, "id") 101 | userID := authn.AccountIDFromContext(r) 102 | err = appCtx.db.Task.Update().Where(task.And( 103 | task.Owner(userID), task.ID(id), 104 | )).SetText(req.Text).Exec(r.Context()) 105 | if err != nil { 106 | return nil, fmt.Errorf("%w", err) 107 | } 108 | 109 | return nil, nil 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/webhook.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/go-chi/render" 9 | 10 | "github.com/stripe/stripe-go/v72/webhook" 11 | 12 | "github.com/go-chi/chi" 13 | ) 14 | 15 | func handleWebhook(appCtx Context) http.HandlerFunc { 16 | return func(w http.ResponseWriter, r *http.Request) { 17 | source := chi.URLParam(r, "source") 18 | 19 | b, err := ioutil.ReadAll(r.Body) 20 | if err != nil { 21 | log.Printf("ioutil.ReadAll: %v", err) 22 | render.Status(r, http.StatusBadRequest) 23 | return 24 | } 25 | 26 | switch source { 27 | case "stripe": 28 | event, err := webhook.ConstructEvent(b, r.Header.Get("Stripe-Signature"), appCtx.cfg.StripeWebhookSecret) 29 | if err != nil { 30 | log.Printf("webhook.ConstructEvent: %v", err) 31 | render.Status(r, http.StatusBadRequest) 32 | return 33 | } 34 | 35 | if event.Type != "checkout.session.completed" { 36 | return 37 | } 38 | 39 | log.Printf("webhook %+v\n", event) 40 | } 41 | 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-proposal-class-properties" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gomodest", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "build": "env NODE_ENV=production webpack", 8 | "watch": "env NODE_ENV=development webpack --watch" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@babel/core": "^7.7.7", 14 | "@babel/plugin-proposal-class-properties": "^7.7.4", 15 | "@babel/preset-env": "^7.7.7", 16 | "@hotwired/turbo": "^7.0.0-beta.4", 17 | "babel-loader": "^8.0.6", 18 | "bulma": "^0.9.1", 19 | "copy-webpack-plugin": "^6.0.2", 20 | "css-loader": "^3.5.3", 21 | "extract-text-webpack-plugin": "^4.0.0-beta.0", 22 | "glob-all": "^3.2.1", 23 | "mini-css-extract-plugin": "^0.9.0", 24 | "node-sass": "^4.14.1", 25 | "optimize-css-assets-webpack-plugin": "^5.0.3", 26 | "purgecss-webpack-plugin": "^2.2.0", 27 | "sass-loader": "^8.0.2", 28 | "stimulus": "^2.0.0", 29 | "stimulus-use": "^0.23.0", 30 | "style-loader": "^1.2.1", 31 | "svelte": "^3.24.1", 32 | "svelte-loader": "^2.13.6", 33 | "watch": "^1.0.2", 34 | "webpack": "^4.43.0", 35 | "webpack-bundle-analyzer": "^3.8.0", 36 | "webpack-cli": "^3.3.11", 37 | "webpack-dev-middleware": "^3.7.2" 38 | }, 39 | "dependencies": { 40 | "svelte-tags-input": "^2.6.5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /assets/src/components/App.svelte: -------------------------------------------------------------------------------- 1 | 42 | 43 | 44 |
45 |
46 |
47 |

todos

48 |
49 |
50 | 51 |
52 |
53 | 58 |
59 |
60 |
    0}> 61 | {#each todos as todo (todo.id)} 62 |
  • 63 |
    64 | {todo.text} 65 |
    66 | 71 |
    72 |
  • 73 | {:else} 74 |
  • Nothing here! 76 |
  • 77 | {/each} 78 |
79 |
80 |
81 |
-------------------------------------------------------------------------------- /assets/src/components/Groups.svelte: -------------------------------------------------------------------------------- 1 | List of Groups -------------------------------------------------------------------------------- /assets/src/components/Guests.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 | 8 |
9 |
10 | 11 |
12 |
13 | 14 | -------------------------------------------------------------------------------- /assets/src/components/Members.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 | 8 |
9 |
10 | 11 |
12 |
13 | 14 | -------------------------------------------------------------------------------- /assets/src/components/index.js: -------------------------------------------------------------------------------- 1 | import app from "./App.svelte" 2 | import members from "./Members.svelte" 3 | import groups from "./Groups.svelte" 4 | import guests from "./Guests.svelte" 5 | 6 | // export other components here. 7 | export default { 8 | app: app, 9 | members:members, 10 | groups: groups, 11 | guests: guests 12 | } -------------------------------------------------------------------------------- /assets/src/controllers/account_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "stimulus" 2 | 3 | export default class extends Controller { 4 | static targets = [ "name","email","magicEmail","password", "confirmPassword", "formError","magic"] 5 | 6 | validateEmail(e) { 7 | if (!this.emailTarget.validity.valid || this.emailTarget.value === ''){ 8 | this.showFormError("Invalid Email") 9 | return false; 10 | } 11 | return true; 12 | } 13 | 14 | validateMagicEmail(e) { 15 | if (!this.magicEmailTarget.validity.valid || this.magicEmailTarget.value === ''){ 16 | this.showFormError("Invalid Email") 17 | return false; 18 | } 19 | return true; 20 | } 21 | 22 | 23 | validateName() { 24 | if (this.nameTarget.value === ''){ 25 | this.showFormError("Name cannot be empty") 26 | return false; 27 | } 28 | return true; 29 | } 30 | 31 | validatePassword() { 32 | if (this.passwordTarget.value === '' || this.passwordTarget.value.length < 8){ 33 | this.showFormError("Minimum 8 character password is required") 34 | return false; 35 | } 36 | return true; 37 | } 38 | 39 | submitForgetForm(e){ 40 | if (this.validateEmail()){ 41 | return; 42 | } 43 | e.preventDefault(); 44 | } 45 | 46 | submitSignupForm(e){ 47 | if (this.validateEmail() && this.validatePassword() && this.validateName()){ 48 | return; 49 | } 50 | e.preventDefault(); 51 | } 52 | 53 | submitLoginForm(e){ 54 | if (this.validateEmail() && this.validatePassword()){ 55 | return; 56 | } 57 | e.preventDefault(); 58 | } 59 | 60 | submitMagicLoginForm(e){ 61 | if (this.validateMagicEmail()){ 62 | return; 63 | } 64 | e.preventDefault(); 65 | } 66 | 67 | submitResetForm(e){ 68 | if (this.validatePassword() &&(this.passwordTarget.value === this.confirmPasswordTarget.value)){ 69 | return; 70 | } 71 | 72 | this.showFormError("Password's don't match") 73 | e.preventDefault(); 74 | } 75 | 76 | submitAccountForm(e){ 77 | if (this.validateEmail() && this.validateName()){ 78 | return; 79 | } 80 | e.preventDefault(); 81 | } 82 | 83 | showFormError(message){ 84 | this.formErrorTarget.classList.remove("is-hidden") 85 | this.formErrorTarget.innerHTML = message 86 | } 87 | 88 | hideFormError(){ 89 | this.formErrorTarget.classList.add("is-hidden") 90 | e.preventDefault(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /assets/src/controllers/clipboard_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "stimulus" 2 | 3 | export default class extends Controller { 4 | static targets = [ "source", "copied" ] 5 | static classes = [ "copied" ] 6 | copy(e) { 7 | e.preventDefault() 8 | this.sourceTarget.select() 9 | document.execCommand("copy") 10 | this.copiedTarget.classList.remove(this.copiedClass) 11 | } 12 | } -------------------------------------------------------------------------------- /assets/src/controllers/hover_hidden_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from 'stimulus' 2 | import { useHover } from 'stimulus-use' 3 | 4 | export default class extends Controller { 5 | static targets = ["tools"] 6 | connect() { 7 | useHover(this, { element: this.element }); 8 | } 9 | 10 | mouseEnter() { 11 | this.toolsTarget.classList.remove('is-hidden') 12 | } 13 | 14 | mouseLeave() { 15 | // ... 16 | this.toolsTarget.classList.add('is-hidden') 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /assets/src/controllers/navigate_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "stimulus" 2 | 3 | export default class extends Controller { 4 | static targets = ["modal","toggler","dropup"] 5 | static classes = [ "active" ] 6 | 7 | connect(){ 8 | 9 | } 10 | 11 | goto(e){ 12 | if (e.currentTarget.dataset.goto){ 13 | window.location = e.currentTarget.dataset.goto; 14 | } 15 | } 16 | 17 | goback(e){ 18 | window.history.back(); 19 | } 20 | 21 | openModal(e){ 22 | const targetModal = this.modalTargets.find(i => i.id === e.currentTarget.dataset.modalTargetId); 23 | targetModal.classList.add("is-active") 24 | e.preventDefault(); 25 | } 26 | 27 | closeModal(e){ 28 | if (e.type === "click"){ 29 | const targetModal = this.modalTargets.find(i => i.id === e.currentTarget.dataset.modalTargetId); 30 | targetModal.classList.remove("is-active") 31 | e.preventDefault(); 32 | return; 33 | } 34 | } 35 | 36 | keyDown(e){ 37 | if (e.keyCode === 27){ 38 | this.modalTargets.forEach(item => { 39 | item.classList.remove("is-active") 40 | }) 41 | } 42 | 43 | if (e.keyCode === 37){ 44 | window.history.back(); 45 | } 46 | 47 | 48 | if (e.keyCode === 39){ 49 | window.history.forward(); 50 | } 51 | } 52 | 53 | toggle(e){ 54 | if (!e.currentTarget.dataset.toggleIds){ 55 | return; 56 | } 57 | const targetToggleIds = e.currentTarget.dataset.toggleIds.split(","); 58 | const targetToggleClass = e.currentTarget.dataset.toggleClass; 59 | targetToggleIds.forEach(item => { 60 | document.getElementById(item).classList.toggle(targetToggleClass); 61 | }) 62 | } 63 | 64 | toggleIsActive(e){ 65 | this.dropupTarget.classList.toggle(this.activeClass) 66 | } 67 | } 68 | 69 | -------------------------------------------------------------------------------- /assets/src/controllers/subscription_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "stimulus" 2 | 3 | export default class extends Controller { 4 | static values = { price: String, stripe: String } 5 | 6 | connect(){ 7 | this.stripe = Stripe(this.stripeValue); 8 | } 9 | 10 | showErrorMessage(message) { 11 | let errorEl = document.getElementById("error-message") 12 | errorEl.textContent = message; 13 | errorEl.style.display = "block"; 14 | }; 15 | 16 | handleFetchResult(result) { 17 | const self = this; 18 | if (!result.ok) { 19 | return result.json().then(function(json) { 20 | if (json.error && json.error.message) { 21 | throw new Error(result.url + ' ' + result.status + ' ' + json.error.message); 22 | } 23 | }).catch(function(err) { 24 | self.showErrorMessage(err); 25 | throw err; 26 | }); 27 | } 28 | return result.json(); 29 | }; 30 | 31 | handleResult(result) { 32 | if (result.error) { 33 | this.showErrorMessage(result.error.message); 34 | } 35 | }; 36 | 37 | createCheckoutSession(priceId) { 38 | const self = this; 39 | return fetch("/account/checkout", { 40 | method: "POST", 41 | headers: { 42 | "Content-Type": "application/json" 43 | }, 44 | body: JSON.stringify({ 45 | price: priceId 46 | }) 47 | }).then(self.handleFetchResult); 48 | }; 49 | 50 | 51 | createCheckoutClick(){ 52 | const self = this; 53 | this.createCheckoutSession(this.priceValue).then(function(data) { 54 | // Call Stripe.js method to redirect to the new Checkout page 55 | self.stripe 56 | .redirectToCheckout({ 57 | sessionId: data.sessionId 58 | }) 59 | .then(self.handleResult); 60 | }); 61 | //console.log("values => ",this.priceValue, this.stripeValue) 62 | } 63 | } -------------------------------------------------------------------------------- /assets/src/controllers/svelte_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "stimulus"; 2 | import components from "../components"; 3 | 4 | export default class extends Controller { 5 | static targets = ["component"] 6 | connect() { 7 | if (this.componentTargets.length > 0){ 8 | this.componentTargets.forEach(el => { 9 | const componentName = el.dataset.componentName; 10 | const componentProps = el.dataset.componentProps ? JSON.parse(el.dataset.componentProps): {}; 11 | if (!(componentName in components)){ 12 | console.log(`svelte component: ${componentName}, not found!`) 13 | return; 14 | } 15 | const app = new components[componentName]({ 16 | target: el, 17 | props: componentProps 18 | }); 19 | }) 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /assets/src/controllers/tabs_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "stimulus" 2 | 3 | export default class extends Controller { 4 | 5 | static targets = [ "tab", "tabPanel" ] 6 | static values = { 7 | defaultTabkey: String, 8 | disableHistory: Boolean 9 | } 10 | static classes = [ "active", "hidden" ] 11 | 12 | connect() { 13 | let activeTabKey = undefined; 14 | if (window.location.hash && !this.disableHistoryValue){ 15 | activeTabKey = window.location.hash.substring(1); 16 | }else if (!this.disableHistoryValue){ 17 | activeTabKey = localStorage.getItem('tabkey') 18 | } 19 | if (!activeTabKey){ 20 | activeTabKey = this.defaultTabkeyValue; 21 | } 22 | 23 | this.activateTab(activeTabKey) 24 | } 25 | 26 | activateTab(tabkey){ 27 | this.tabPanelTargets.forEach((el, i) => { 28 | if(el.dataset.tabkey === tabkey){ 29 | el.classList.remove(this.hiddenClass) 30 | } else { 31 | el.classList.add(this.hiddenClass) 32 | } 33 | }) 34 | 35 | this.tabTargets.forEach((el, i) => { 36 | if(el.dataset.tabkey === tabkey){ 37 | el.classList.add(this.activeClass) 38 | if (!this.disableHistoryValue){ 39 | history.replaceState(null,null,`#${el.dataset.tabkey}`) 40 | localStorage.setItem("tabkey",el.dataset.tabkey) 41 | } 42 | } else { 43 | el.classList.remove(this.activeClass) 44 | } 45 | }) 46 | } 47 | 48 | activate(e){ 49 | let tabkey = undefined; 50 | if(e.currentTarget.dataset.tabkey){ 51 | tabkey = e.currentTarget.dataset.tabkey; 52 | } else if(e.currentTarget.parentElement.dataset.tabkey){ 53 | tabkey = e.currentTarget.parentElement.dataset.tabkey 54 | } 55 | this.activateTab(tabkey); 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /assets/src/controllers/toggle_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "stimulus" 2 | 3 | export default class extends Controller { 4 | static targets = [ "toggled" ] 5 | static values = { 6 | toggleClass: String 7 | } 8 | 9 | it(e){ 10 | e.preventDefault() 11 | this.toggledTargets.forEach(item => { 12 | console.log("item") 13 | item.classList.toggle(this.toggleClassValue) 14 | }) 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /assets/src/index.js: -------------------------------------------------------------------------------- 1 | import { Application } from "stimulus" 2 | import { definitionsFromContext } from "stimulus/webpack-helpers" 3 | import * as Turbo from "@hotwired/turbo" 4 | import "./styles.scss"; 5 | const application = Application.start() 6 | const context = require.context("./controllers", true, /\.js$/) 7 | application.load(definitionsFromContext(context)) 8 | -------------------------------------------------------------------------------- /assets/src/styles.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | 4 | .h-full { 5 | min-height: 101vh !important; 6 | } 7 | 8 | .width-320 { 9 | width: 320px !important; 10 | } 11 | 12 | //$navbar-background-color: #002C73; 13 | //$primary: #002C73; 14 | 15 | @import "~bulma/bulma"; 16 | .is-clickable:hover { 17 | cursor: pointer; 18 | } 19 | 20 | 21 | 22 | 23 | // https://bulma.io/documentation/customize/with-webpack/#9-add-your-own-bulma-styles -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const glob = require('glob-all') 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 4 | const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 5 | const PurgeCssPlugin = require('purgecss-webpack-plugin') 6 | const CopyPlugin = require('copy-webpack-plugin'); 7 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 8 | 9 | const PATHS = { 10 | html: path.join(__dirname, '../templates'), 11 | src: path.join(__dirname, 'src') 12 | } 13 | 14 | const env = process.env.NODE_ENV; 15 | 16 | 17 | module.exports = { 18 | mode: env === 'production' || env === 'none' ? env : 'development', 19 | entry: './src/index.js', 20 | resolve: { 21 | alias: { 22 | svelte: path.resolve('node_modules', 'svelte') 23 | }, 24 | extensions: ['.mjs', '.js', '.svelte'], 25 | mainFields: ['svelte', 'browser', 'module', 'main'] 26 | }, 27 | output: { 28 | path: path.resolve(__dirname, '../public'), 29 | filename: 'assets/js/bundle.js' 30 | }, 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.svelte$/, 35 | use: { 36 | loader: 'svelte-loader', 37 | options: { 38 | emitCss: true, 39 | hotReload: true 40 | } 41 | } 42 | }, 43 | { 44 | test: /\.js$/, 45 | exclude: [ 46 | /node_modules/ 47 | ], 48 | use: [ 49 | {loader: "babel-loader"} 50 | ] 51 | }, 52 | { 53 | test: /\.css$/, 54 | use: [ 55 | /** 56 | * MiniCssExtractPlugin doesn't support HMR. 57 | * For developing, use 'style-loader' instead. 58 | * */ 59 | env === 'production' ? MiniCssExtractPlugin.loader : 'style-loader', 60 | 'css-loader' 61 | ] 62 | }, 63 | { 64 | test: /\.scss$/, 65 | use: [ 66 | MiniCssExtractPlugin.loader, 67 | { 68 | loader: 'css-loader' 69 | }, 70 | { 71 | loader: 'sass-loader', 72 | options: { 73 | sourceMap: true, 74 | // options... 75 | } 76 | } 77 | ] 78 | }] 79 | }, 80 | plugins: [ 81 | new MiniCssExtractPlugin({ 82 | filename: 'assets/css/styles.css' 83 | }), 84 | new CopyPlugin({ 85 | patterns: [ 86 | { from: 'images', to: 'assets/images' }, 87 | ], 88 | }), 89 | // new BundleAnalyzerPlugin(), 90 | ] 91 | }; 92 | 93 | if (env === 'production') { 94 | module.exports.plugins.push( 95 | new OptimizeCssAssetsPlugin({ 96 | cssProcessorPluginOptions: { 97 | preset: ['default', { discardComments: { removeAll: true } }] 98 | } 99 | }) 100 | ); 101 | 102 | module.exports.plugins.push( 103 | new PurgeCssPlugin({ 104 | paths: glob.sync([ 105 | `${PATHS.html}/**/*`, 106 | `${PATHS.src}/**/*`], 107 | {nodir: true}), 108 | }), 109 | ); 110 | } -------------------------------------------------------------------------------- /env.development: -------------------------------------------------------------------------------- 1 | APP_DOMAIN=http://localhost:4000 2 | APP_LOG_LEVEL=error 3 | APP_GOOGLE_CLIENT_ID= 4 | APP_GOOGLE_SECRET= 5 | APP_SMTP_HOST= 6 | APP_SMTP_PORT=587 7 | APP_SMTP_USER= 8 | APP_SMTP_PASS= 9 | APP_SMTP_ADMIN_EMAIL= 10 | APP_API_MASTER_SECRET= 11 | APP_STRIPE_PUBLISHABLE_KEY= 12 | APP_STRIPE_SECRET_KEY= 13 | APP_STRIPE_WEBHOOK_SECRET= 14 | APP_PLANS_FILE= -------------------------------------------------------------------------------- /feature_groups.development.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Usage", 4 | "features": [ 5 | { 6 | "id": "api_calls", 7 | "title": "API Calls", 8 | "value_type": "int" 9 | }, 10 | { 11 | "id": "events", 12 | "title": "Events", 13 | "value_type": "int" 14 | }, 15 | { 16 | "id": "alerts", 17 | "title": "Alerts", 18 | "value_type": "int" 19 | } 20 | ] 21 | } 22 | ] -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/adnaan/gomodest-starter 2 | 3 | go 1.16 4 | 5 | require ( 6 | cloud.google.com/go v0.74.0 // indirect 7 | entgo.io/ent v0.7.0 8 | github.com/PuerkitoBio/goquery v1.6.0 // indirect 9 | github.com/adnaan/authn v0.0.2 10 | github.com/adnaan/renderlayout v0.0.3 11 | github.com/andybalholm/cascadia v1.2.0 // indirect 12 | github.com/go-chi/chi v4.1.2+incompatible 13 | github.com/go-chi/httplog v0.1.8 14 | github.com/go-chi/render v1.0.1 15 | github.com/go-chi/valve v0.0.0-20170920024740-9e45288364f4 16 | github.com/go-playground/form v3.1.4+incompatible 17 | github.com/golang/protobuf v1.5.1 // indirect 18 | github.com/google/uuid v1.2.0 19 | github.com/gorilla/mux v1.8.0 // indirect 20 | github.com/hako/branca v0.0.0-20200807062402-6052ac720505 21 | github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect 22 | github.com/joho/godotenv v1.3.0 23 | github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible 24 | github.com/kelseyhightower/envconfig v1.4.0 25 | github.com/lithammer/shortuuid/v3 v3.0.5 26 | github.com/markbates/goth v1.67.1 27 | github.com/matcornic/hermes/v2 v2.1.0 28 | github.com/mattn/go-sqlite3 v1.14.6 29 | github.com/mholt/binding v0.3.0 30 | github.com/rs/zerolog v1.20.0 // indirect 31 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 32 | github.com/stripe/stripe-go/v72 v72.28.0 33 | github.com/vanng822/go-premailer v1.9.0 // indirect 34 | golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect 35 | golang.org/x/mod v0.4.1 // indirect 36 | golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4 // indirect 37 | golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/adnaan/gomodest-starter/app" 16 | 17 | "github.com/go-chi/chi" 18 | 19 | "github.com/go-chi/valve" 20 | ) 21 | 22 | // fileServer conveniently sets up a http.FileServer handler to serve 23 | // static files from a http.FileSystem. 24 | func fileServer(r chi.Router, path string, root http.FileSystem) { 25 | if strings.ContainsAny(path, "{}*") { 26 | panic("FileServer does not permit any URL parameters.") 27 | } 28 | 29 | if path != "/" && path[len(path)-1] != '/' { 30 | r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP) 31 | path += "/" 32 | } 33 | path += "*" 34 | 35 | r.Get(path, func(w http.ResponseWriter, r *http.Request) { 36 | rctx := chi.RouteContext(r.Context()) 37 | pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*") 38 | fs := http.StripPrefix(pathPrefix, http.FileServer(root)) 39 | fs.ServeHTTP(w, r) 40 | }) 41 | } 42 | 43 | func main() { 44 | // Our graceful valve shut-off package to manage code preemption and 45 | // shutdown signaling. 46 | vv := valve.New() 47 | baseCtx := vv.Context() 48 | configFile := flag.String("config", "", "path to config file") 49 | envPrefix := os.Getenv("ENV_PREFIX") 50 | if envPrefix == "" { 51 | envPrefix = "app" 52 | } 53 | flag.Parse() 54 | 55 | cfg, err := app.LoadConfig(*configFile, envPrefix) 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | 60 | r := app.Router(baseCtx, cfg) 61 | workDir, _ := os.Getwd() 62 | public := http.Dir(filepath.Join(workDir, "./", "public", "assets")) 63 | fileServer(r, "/static", public) 64 | 65 | srv := &http.Server{ 66 | ReadTimeout: time.Duration(cfg.ReadTimeoutSecs) * time.Second, 67 | WriteTimeout: time.Duration(cfg.WriteTimeoutSecs) * time.Second, 68 | Addr: fmt.Sprintf(":%d", cfg.Port), 69 | Handler: r, 70 | } 71 | 72 | c := make(chan os.Signal, 1) 73 | signal.Notify(c, os.Interrupt) 74 | go func() { 75 | for range c { 76 | // sig is a ^C, handle it 77 | fmt.Println("shutting down..") 78 | 79 | // first valv 80 | vv.Shutdown(20 * time.Second) 81 | 82 | // create context with timeout 83 | ctx, cancel := context.WithTimeout(baseCtx, 20*time.Second) 84 | defer cancel() 85 | 86 | // start http shutdown 87 | srv.Shutdown(ctx) 88 | 89 | // verify, in worst case call cancel via defer 90 | select { 91 | case <-time.After(21 * time.Second): 92 | fmt.Println("not all connections done") 93 | case <-ctx.Done(): 94 | 95 | } 96 | } 97 | }() 98 | log.Println("http server is listening...") 99 | srv.ListenAndServe() 100 | } 101 | -------------------------------------------------------------------------------- /plans.development.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "price_id": "free", 4 | "name": "Free", 5 | "price": "0", 6 | "details": [ 7 | "1000 API Calls", 8 | "10K Events", 9 | "100 Alerts" 10 | ] 11 | }, 12 | { 13 | "price_id": "pro", 14 | "name": "Pro", 15 | "price": "10", 16 | "details": [ 17 | "10000 API Calls", 18 | "100K Events", 19 | "1000 Alerts" 20 | ] 21 | } 22 | ] -------------------------------------------------------------------------------- /stripe.md: -------------------------------------------------------------------------------- 1 | ```bash 2 | stripe products create --name="Free" --description="Free plan" 3 | stripe prices create \ 4 | -d product=prod_IZQ0fAMigCUFX1 \ 5 | -d unit_amount=0 \ 6 | -d currency=eur \ 7 | -d "recurring[interval]"=month 8 | stripe products create --name="Pro" --description="Pro plan" 9 | stripe prices create \ 10 | -d product=prod_IZQ2RckEQabB3y \ 11 | -d unit_amount=10 \ 12 | -d currency=eur \ 13 | -d "recurring[interval]"=month 14 | ``` -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 |
5 |

404 Page not found

6 |
7 | 10 |
11 |
12 | {{end}} -------------------------------------------------------------------------------- /templates/account/changed.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 |

Confirm email change.

5 |
6 |
7 | 8 | {{end}} -------------------------------------------------------------------------------- /templates/account/confirmed.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 | Email confirmed 5 | 6 |
7 |
8 | 9 | {{end}} -------------------------------------------------------------------------------- /templates/account/forgot.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 | 5 | {{template "errors" .}} 6 | {{ if .recovery_sent }} 7 | A password recovery link has been sent to your inbox. 8 | {{else}} 9 | 10 |
13 |
14 | 15 | 23 | 24 |
25 |
26 |
27 | 31 |
32 |
33 |
34 | {{end}} 35 |
36 |
37 |
38 | {{end}} -------------------------------------------------------------------------------- /templates/account/login.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 | 5 | {{template "errors" .}} 6 | {{ if .confirmed }} 7 | 8 | Thank you for confirming your email. Sign in to get started ! 9 | 10 | {{end}} 11 | 12 | {{ if .not_confirmed }} 13 | 14 | Email not confirmed yet ! An email was sent to your inbox for confirmation. 15 | Please confirm to login. 16 | 17 | {{end}} 18 | 19 | {{ if .confirmation_sent }} 20 | 21 | An email has been sent to your inbox for confirmation. Please confirm to login. 22 | 23 | {{end}} 24 | 25 |
26 |

Sign in to {{.app_name}}

27 |
28 | 29 |
31 | 72 | 73 |
74 |
75 | 79 |
80 |
84 |

{{.userErrs}}

85 |
86 | 87 | 94 | 95 |
96 | 97 |
98 | 99 | 104 |
105 | 106 | 107 |
108 |
109 | 113 |
114 |
115 | 121 |
122 |
123 | 127 | Forgot Password ? 128 | 129 |
130 |
131 |
132 |
133 |
134 | 143 |
144 |
145 |
146 |
147 | {{end}} -------------------------------------------------------------------------------- /templates/account/magic.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 |
5 |

6 | We have sent a one time login link to your email inbox. 7 | Simply click on the link to login. 8 |

9 |
10 | 11 |
12 |
13 | 14 | {{end}} -------------------------------------------------------------------------------- /templates/account/main.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 | {{ if .change_email }} 5 | A verification link has been sent to your inbox for confirming your new 6 | email address. Please verify to complete the request. 7 | 8 | {{end}} 9 | 10 | {{ if .email_changed }} 11 | Your email was updated! 12 | {{end}} 13 | 14 | {{ if .checkout }} 15 | {{ if eq .checkout "success"}} 16 | Payment was successful! 17 | {{end}} 18 | {{ if eq .checkout "cancel"}} 19 | Payment was cancelled! 20 | {{end}} 21 | {{end}} 22 | 23 | 24 |
29 |
30 | 68 |
69 |
70 | 76 | 82 | 88 |
89 |
90 | 91 | 92 |
93 |
94 | 95 | {{end}} -------------------------------------------------------------------------------- /templates/account/main_workspace.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 | 3 |
4 |
5 | {{ if .change_email }} 6 | A verification link has been sent to your inbox for confirming your new 7 | email address. Please verify to complete the request. 8 | 9 | {{end}} 10 | 11 | {{ if .email_changed }} 12 | Your email was updated! 13 | {{end}} 14 | 15 | {{ if .checkout }} 16 | {{ if eq .checkout "success"}} 17 | Payment was successful! 18 | {{end}} 19 | {{ if eq .checkout "cancel"}} 20 | Payment was cancelled! 21 | {{end}} 22 | {{end}} 23 | 24 | {{ template "workspace_switcher" .}} 25 | 26 |
31 |
32 | 77 |
78 |
79 | 85 | 91 | 97 | 103 |
104 |
105 | 106 | 107 |
108 |
109 | 110 | {{end}} -------------------------------------------------------------------------------- /templates/account/reset.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 | 5 | {{template "errors" .}} 6 |

Change to a new pasword

7 |
8 | 9 |
12 |
13 | 14 | 21 | 22 |
23 |
24 | 25 | 32 | 33 |
34 |
35 |
36 | 40 |
41 |
42 |
43 |
44 |
45 |
46 | {{end}} -------------------------------------------------------------------------------- /templates/account/signup.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 | 5 | {{template "errors" .}} 6 |
7 |

Sign up with {{.app_name}}

8 |
9 |
12 |
13 | 14 | 21 | 22 |
23 |
24 | 25 | 33 | 34 |
35 | 36 |
37 | 38 | 44 | 45 |
46 |
47 |
48 | 52 |
53 |
54 | 60 |
61 |
62 | 63 |
64 |
65 | 74 |
75 |
76 |
77 |
78 |
79 | {{end}} -------------------------------------------------------------------------------- /templates/app.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 | 5 | {{template "errors" .}} 6 |
7 |
8 |
9 | 13 |
14 |
15 | 21 |
22 |
23 |
24 |
25 | {{ range .tasks }} 26 | 27 |
28 |
29 |
30 |
31 | {{.Text}} 32 |
33 | 34 |
35 | 55 |
56 |
57 | 86 | 87 | 112 | {{ end }} 113 |
114 |
115 |
116 |
117 | {{end}} -------------------------------------------------------------------------------- /templates/home.html: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 |

{{.app_name}} is inspired from modestjs approaches to building webapps as enlisted in https://modestjs.works/.

5 |
6 |

Using Go, SveltJS, StimulusJS and Bulma.css

7 | 8 |
9 |
10 | 11 | {{end}} -------------------------------------------------------------------------------- /templates/layouts/empty.html: -------------------------------------------------------------------------------- 1 | {{template "content" .}} -------------------------------------------------------------------------------- /templates/layouts/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{.app_name}} 6 | {{include "partials/header"}} 7 | 8 | 11 | {{include "partials/navbar"}} 12 |
13 |
14 |
15 | 18 |
19 |
20 | {{template "content" .}} 21 |
22 | {{include "partials/footer"}} 23 | 24 | 25 | -------------------------------------------------------------------------------- /templates/partials/developer.html: -------------------------------------------------------------------------------- 1 | {{define "developer"}} 2 |
3 |

Api Token

4 |
5 | {{ if .is_api_token_set }} 6 | {{ if .api_token }} 7 | 8 | 10 |
11 | 16 | 17 |
18 |
19 |
20 |
21 |

22 | Please copy the API Token and save it in a safe place. 23 | You won't be able to see the token again once this page is closed or reloaded. 24 |

25 |
26 | 27 | {{ else }} 28 |

You have previously generated an API Token and hopefully saved it in a safe place. 29 |

30 | 35 | {{ end }} 36 | {{ else }} 37 | 42 | {{ end }} 43 | 44 | 78 |
79 | {{end}} -------------------------------------------------------------------------------- /templates/partials/errors.html: -------------------------------------------------------------------------------- 1 | {{define "errors"}} 2 | {{if .errors}} 3 |
4 | {{ range .errors }} 5 |
  • {{.}}
  • 6 | {{end}} 7 |
    8 | {{end}} 9 | {{end}} -------------------------------------------------------------------------------- /templates/partials/footer.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    4 | {{.app_name}}, 2020 5 |

    6 |
    7 |
    -------------------------------------------------------------------------------- /templates/partials/groups.html: -------------------------------------------------------------------------------- 1 | {{define "groups"}} 2 |
    6 |
    7 | {{end}} -------------------------------------------------------------------------------- /templates/partials/guests.html: -------------------------------------------------------------------------------- 1 | {{define "guests"}} 2 |
    5 |
    6 | {{end}} -------------------------------------------------------------------------------- /templates/partials/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /templates/partials/list_members.html: -------------------------------------------------------------------------------- 1 | {{define "list_members"}} 2 |
    6 |
    7 | {{end}} -------------------------------------------------------------------------------- /templates/partials/members.html: -------------------------------------------------------------------------------- 1 | {{define "members"}} 2 |
    8 |
    9 | 23 |
    24 | 25 | 30 | 35 | 40 | 41 |
    42 | {{end}} -------------------------------------------------------------------------------- /templates/partials/navbar.html: -------------------------------------------------------------------------------- 1 | 49 | -------------------------------------------------------------------------------- /templates/partials/plan.html: -------------------------------------------------------------------------------- 1 | {{define "plan"}} 2 |
    3 | 114 |
    115 | {{end}} -------------------------------------------------------------------------------- /templates/partials/plans.html: -------------------------------------------------------------------------------- 1 | {{define "plans"}} 2 |
    3 |

    Plans

    4 |
    5 |
    6 | 7 | 8 | 9 | {{ $currentPlan := .current_plan }} 10 | 11 | {{ range .plans}} 12 | 48 | {{ end }} 49 | 50 | 51 | 52 | {{ $plans := .plans }} 53 | {{ range .feature_groups }} 54 | 55 | 56 | {{ range $plans }} 57 | 58 | {{ end }} 59 | 60 | 61 | {{ range .Features }} 62 | {{$featureID := .ID}} 63 | 64 | 65 | {{ range $plans }} 66 | 67 | {{ end }} 68 | 69 | {{ end }} 70 | 71 | {{ end }} 72 | 73 |
    16 |
    {{.Name}}
    17 |
    ${{.Price}} per month
    18 |
    19 | {{ if $currentPlan }} 20 | {{ if eq $currentPlan.PriceID .PriceID }} 21 |
    22 |
    23 | Current plan 24 |
    25 |
    26 |
    27 | 32 |
    33 | {{ end }} 34 | {{ else }} 35 | {{ if eq .PriceID "default" }} 36 |
    37 | Current plan 38 |
    39 | {{ else }} 40 | 44 | {{ end }} 45 | {{ end }} 46 |
    47 |
    {{ .Name }}
    {{.Title}} {{ index .Features $featureID}}
    74 |
    75 |
    76 | {{end}} -------------------------------------------------------------------------------- /templates/partials/profile.html: -------------------------------------------------------------------------------- 1 | {{define "profile"}} 2 |
    3 |

    Profile

    4 |
    5 |
    8 | 9 | {{if .Error}} 10 |
    11 | {{.Error}} 12 |
    13 | {{end}} 14 |
    15 | 16 |
    17 | 26 |
    27 |
    28 | 29 | {{if .email}} 30 |
    31 | 32 |
    33 | 42 |
    43 |
    44 | {{end}} 45 | 46 |
    47 |

    48 | 51 |

    52 |
    53 |
    54 |
    55 |
    56 |
    57 |

    Delete account

    58 |
    59 |

    All data related to your account will be completely removed and unrecoverable.

    60 | 65 | 96 |
    97 | {{end}} -------------------------------------------------------------------------------- /templates/partials/workspace_switcher.html: -------------------------------------------------------------------------------- 1 | {{define "workspace_switcher"}} 2 |
    3 |
    4 | 35 |
    36 |
    37 |
    38 |
    39 | {{end}} --------------------------------------------------------------------------------