From 1062fc7c687cd3555ad79df0dbe816da77dff887 Mon Sep 17 00:00:00 2001 From: Elisiei Yehorov Date: Fri, 17 Apr 2026 22:05:05 +0200 Subject: [PATCH] chore: base --- .envrc | 1 + .gitignore | 33 ++ cmd/server/main.go | 46 ++ flake.lock | 25 + flake.nix | 47 ++ go.mod | 21 + go.sum | 38 ++ internal/admin/admin.go | 42 ++ internal/admin/router.go | 46 ++ internal/bot/bot.go | 85 ++++ internal/bot/input.go | 30 ++ internal/bot/router.go | 110 ++++ internal/db/bot_co_owners.sql.go | 138 ++++++ internal/db/bots.sql.go | 468 ++++++++++++++++++ internal/db/custom.go | 43 ++ internal/db/db.go | 32 ++ internal/db/models.go | 44 ++ internal/db/querier.go | 43 ++ .../db/sql/migrations/20260323184653_init.sql | 62 +++ internal/db/sql/queries/bot_co_owners.sql | 28 ++ internal/db/sql/queries/bots.sql | 86 ++++ internal/db/sql/queries/users.sql | 28 ++ internal/db/sql/queries/votes.sql | 27 + internal/db/users.sql.go | 110 ++++ internal/db/votes.sql.go | 127 +++++ internal/errorutil/errorutil.go | 62 +++ internal/middleware/auth.go | 57 +++ internal/paginate/paginate.go | 87 ++++ internal/server/server.go | 55 ++ sqlc.yaml | 38 ++ 30 files changed, 2059 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 cmd/server/main.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/admin/admin.go create mode 100644 internal/admin/router.go create mode 100644 internal/bot/bot.go create mode 100644 internal/bot/input.go create mode 100644 internal/bot/router.go create mode 100644 internal/db/bot_co_owners.sql.go create mode 100644 internal/db/bots.sql.go create mode 100644 internal/db/custom.go create mode 100644 internal/db/db.go create mode 100644 internal/db/models.go create mode 100644 internal/db/querier.go create mode 100644 internal/db/sql/migrations/20260323184653_init.sql create mode 100644 internal/db/sql/queries/bot_co_owners.sql create mode 100644 internal/db/sql/queries/bots.sql create mode 100644 internal/db/sql/queries/users.sql create mode 100644 internal/db/sql/queries/votes.sql create mode 100644 internal/db/users.sql.go create mode 100644 internal/db/votes.sql.go create mode 100644 internal/errorutil/errorutil.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/paginate/paginate.go create mode 100644 internal/server/server.go create mode 100644 sqlc.yaml diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50b5dca --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env +.direnv + +# Editor/IDE +# .idea/ +# .vscode/ diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..0560d90 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "log/slog" + "os" + + "codeberg.org/nextgo/dbots/internal/db" + "codeberg.org/nextgo/dbots/internal/server" + "codeberg.org/ungo/env" + "codeberg.org/ungo/env/dotenv" + "codeberg.org/ungo/gonsole" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Config struct { + DatabaseURL string `env:"DATABASE_URL,required"` + Port int `env:"PORT,default=8080"` +} + +var config Config + +func init() { + dotenv.MustLoad() + if err := env.Load(&config); err != nil { + panic(err) + } +} + +func main() { + gonsoleHandler := gonsole.New(os.Stdout, slog.LevelDebug) + slogHandler := slog.New(gonsoleHandler) + slog.SetDefault(slogHandler) + ctx := context.Background() + + conn, err := pgxpool.New(ctx, config.DatabaseURL) + if err != nil { + slog.Error("error connecting to postgres", "err", err) + return + } + + queries := db.New(conn) + server := server.NewServer(queries, slogHandler) + + server.Start(config.Port) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2990a2e --- /dev/null +++ b/flake.lock @@ -0,0 +1,25 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1774106199, + "narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=", + "rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655", + "revCount": 967235, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.967235%2Brev-6c9a78c09ff4d6c21d0319114873508a6ec01655/019d198c-70dc-7753-b1d1-721451f578ae/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ac1d74a --- /dev/null +++ b/flake.nix @@ -0,0 +1,47 @@ +{ + description = "minimal flake for go dev"; + + inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1"; + + outputs = + inputs: + let + goVersion = 26; + + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forEachSupportedSystem = + f: + inputs.nixpkgs.lib.genAttrs supportedSystems ( + system: + f { + pkgs = import inputs.nixpkgs { + inherit system; + overlays = [ inputs.self.overlays.default ]; + }; + } + ); + in + { + overlays.default = final: prev: { + go = final."go_1_${toString goVersion}"; + }; + + devShells = forEachSupportedSystem ( + { pkgs }: + { + default = pkgs.mkShell { + packages = with pkgs; [ + go + sqlc + goose + ]; + }; + } + ); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2b39b99 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module codeberg.org/nextgo/dbots + +go 1.25.8 + +require ( + codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3 + codeberg.org/ungo/gonsole v0.1.0 + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/httplog/v3 v3.3.0 + github.com/go-chi/render v1.0.3 + github.com/jackc/pgx/v5 v5.9.1 +) + +require ( + github.com/ajg/form v1.5.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..985213c --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3 h1:Xn8IiW5uYGajGqYPXU0kS8zXxqRs5E/MTfYjm0O1KrI= +codeberg.org/ungo/env v0.0.0-20260315114019-c4fbd9390cb3/go.mod h1:pXfrNASG7JyxL30Zof3b1vbpd1dsHePTh3zGfPFgJKs= +codeberg.org/ungo/gonsole v0.1.0 h1:QE/qpSyovejIXzIh29tzmrwgDWfaKUqNTCMZPJEDfvY= +codeberg.org/ungo/gonsole v0.1.0/go.mod h1:xPHtFAI4CeujxpwhkiVrRa6pMvWYtaNsC+jJIib5EgA= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/httplog/v3 v3.3.0 h1:Gr6Y7nSzbpyCyRwKPOVKjDH3BH6TH5uvRNDsTZWDpvU= +github.com/go-chi/httplog/v3 v3.3.0/go.mod h1:N/J1l5l1fozUrqIVuT8Z/HzNeSy8TF2EFyokPLe6y2w= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/admin/admin.go b/internal/admin/admin.go new file mode 100644 index 0000000..372148c --- /dev/null +++ b/internal/admin/admin.go @@ -0,0 +1,42 @@ +package admin + +import ( + "context" + "log/slog" + "strings" + + "codeberg.org/nextgo/dbots/internal/db" + "codeberg.org/nextgo/dbots/internal/errorutil" + "codeberg.org/nextgo/dbots/internal/paginate" +) + +type Service struct { + q *db.Queries +} + +func NewService(q *db.Queries) *Service { + return &Service{q: q} +} + +func (s *Service) ListBots(ctx context.Context, status db.BotStatus, p paginate.Params) (paginate.Page[*db.Bot], error) { + status = db.BotStatus(strings.ToLower(status.String())) + total, err := s.q.CountBotsByUsername(ctx, db.CountBotsByUsernameParams{ + Status: status, + }) + if err != nil { + slog.Error("error counting bots", "err", err) + return paginate.Page[*db.Bot]{}, errorutil.ErrSearchFailed + } + + bots, err := s.q.ListBotsByStatus(ctx, db.ListBotsByStatusParams{ + Status: status, + Limit: p.Limit, + Offset: p.Offset, + }) + if err != nil { + slog.Error("error listing bots", "err", err) + return paginate.Page[*db.Bot]{}, errorutil.ErrSearchFailed + } + + return paginate.NewPage(bots, int(total), p), nil +} diff --git a/internal/admin/router.go b/internal/admin/router.go new file mode 100644 index 0000000..8bcba13 --- /dev/null +++ b/internal/admin/router.go @@ -0,0 +1,46 @@ +package admin + +import ( + "net/http" + + "codeberg.org/nextgo/dbots/internal/db" + "codeberg.org/nextgo/dbots/internal/errorutil" + "codeberg.org/nextgo/dbots/internal/paginate" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type Router struct { + admin *Service + router chi.Router +} + +func NewRouter(q *db.Queries) *Router { + return &Router{ + admin: NewService(q), + router: chi.NewRouter(), + } +} + +func (r *Router) Routes() http.Handler { + r.router.Get("/bots", r.listBots) + r.router.Route("/bots", func(router chi.Router) { + + }) + + return r.router +} + +func (r *Router) listBots(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + status := db.BotStatus(req.URL.Query().Get("s")) + p := paginate.ParseParams(req) + + page, err := r.admin.ListBots(ctx, status, p) + if err != nil { + render.Render(w, req, errorutil.ErrInvalidRequest(err)) + return + } + + render.JSON(w, req, page) +} diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..6d0db33 --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,85 @@ +package bot + +import ( + "context" + "errors" + "log/slog" + + "codeberg.org/nextgo/dbots/internal/db" + "codeberg.org/nextgo/dbots/internal/errorutil" + "codeberg.org/nextgo/dbots/internal/middleware" + "codeberg.org/nextgo/dbots/internal/paginate" + "github.com/jackc/pgx/v5/pgconn" +) + +type Service struct { + q *db.Queries +} + +func NewService(q *db.Queries) *Service { + return &Service{q: q} +} + +func (s *Service) Submit(ctx context.Context, data CreateBotRequest) (*db.Bot, error) { + user := middleware.GetUser(ctx) + + var count int32 = 0 + b, err := s.q.CreateBot(ctx, db.CreateBotParams{ + ID: data.ID, + Overview: &data.Overview, + IsSlash: data.IsSlash, + InstallContext: data.InstallContext, + ImportedFrom: nil, + Username: data.Username, + Avatar: &data.Avatar, + Description: &data.Description, + GuildCount: &count, + InstallCount: &count, + Prefix: data.Prefix, + MainOwnerID: &user.ID, + }) + if err != nil { + slog.Error("error submitting bot", "err", err, "bot_id", data.ID) + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "23505": + return nil, errorutil.ErrBotAlreadyExists + } + } + return nil, err + } + + return b, nil +} + +func (s *Service) Get(ctx context.Context, id string) (*db.Bot, error) { + return s.q.GetBot(ctx, id) +} + +func (s *Service) List( + ctx context.Context, + query string, + p paginate.Params, +) (paginate.Page[*db.Bot], error) { + total, err := s.q.CountBotsByUsername(ctx, db.CountBotsByUsernameParams{ + Status: db.BotStatusApproved, + Query: query, + }) + if err != nil { + slog.Error("error counting bots", "query", query, "err", err) + return paginate.Page[*db.Bot]{}, errorutil.ErrSearchFailed + } + + bots, err := s.q.ListBotsByStatus(ctx, db.ListBotsByStatusParams{ + Status: db.BotStatusApproved, + Limit: p.Limit, + Offset: p.Offset, + }) + if err != nil { + slog.Error("error listing bots", "query", query, "err", err) + return paginate.Page[*db.Bot]{}, errorutil.ErrSearchFailed + } + + return paginate.NewPage(bots, int(total), p), nil +} diff --git a/internal/bot/input.go b/internal/bot/input.go new file mode 100644 index 0000000..2a82d14 --- /dev/null +++ b/internal/bot/input.go @@ -0,0 +1,30 @@ +package bot + +import ( + "net/http" + "strconv" + + "codeberg.org/nextgo/dbots/internal/db" + "codeberg.org/nextgo/dbots/internal/errorutil" +) + +type CreateBotRequest struct { + ID string `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar"` + Overview string `json:"overview"` + Description string `json:"description"` + IsSlash bool `json:"is_slash"` + InstallContext db.InstallContext `json:"install_context"` + Prefix *string `json:"prefix"` + ImportedFrom *string `json:"imported_from"` + CoOwners []string `json:"co_owners"` +} + +func (c *CreateBotRequest) Bind(req *http.Request) error { + if _, err := strconv.ParseUint(c.ID, 10, 64); err != nil { + return errorutil.ErrInvalidID + } + + return nil +} diff --git a/internal/bot/router.go b/internal/bot/router.go new file mode 100644 index 0000000..603f5fe --- /dev/null +++ b/internal/bot/router.go @@ -0,0 +1,110 @@ +package bot + +import ( + "context" + "net/http" + + "codeberg.org/nextgo/dbots/internal/db" + "codeberg.org/nextgo/dbots/internal/errorutil" + "codeberg.org/nextgo/dbots/internal/middleware" + "codeberg.org/nextgo/dbots/internal/paginate" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type contextKey string + +const botKey contextKey = "bot" + +type Router struct { + bots *Service + router chi.Router +} + +func NewRouter(q *db.Queries) *Router { + return &Router{ + bots: NewService(q), + router: chi.NewRouter(), + } +} + +func (r *Router) Routes() http.Handler { + r.router. + With(middleware.AuthGuardMiddleware). + Post("/", r.submitBot) + r.router.Get("/", r.listBots) + r.router.Route("/{botID}", func(router chi.Router) { + router.Use(r.BotContext) + router.With(r.BotCache).Get("/", r.getBot) + }) + + return r.router +} + +func (r *Router) submitBot(w http.ResponseWriter, req *http.Request) { + data := &CreateBotRequest{} + + if err := render.Bind(req, data); err != nil { + render.Render(w, req, errorutil.ErrInvalidRequest(err)) + return + } + + ctx := req.Context() + bot, err := r.bots.Submit(ctx, *data) + if err != nil { + render.Render(w, req, errorutil.ErrInvalidRequest(err)) + return + } + + render.Status(req, http.StatusCreated) + render.JSON(w, req, bot) +} + +func (r *Router) getBot(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + + bot, ok := ctx.Value(botKey).(*db.Bot) + if !ok { + render.Render(w, req, errorutil.ErrInvalidRequest(nil)) + return + } + + render.JSON(w, req, bot) +} + +func (r *Router) listBots(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + query := req.URL.Query().Get("q") + p := paginate.ParseParams(req) + + page, err := r.bots.List(ctx, query, p) + if err != nil { + render.Render(w, req, errorutil.ErrInvalidRequest(err)) + return + } + + render.JSON(w, req, page) +} + +func (r *Router) BotContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + botID := chi.URLParam(req, "botID") + bot, err := r.bots.Get(ctx, botID) + if err != nil { + render.Render(w, req, errorutil.ErrNotFound) + return + } + + ctx = context.WithValue(ctx, botKey, bot) + next.ServeHTTP(w, req.WithContext(ctx)) + }) +} + +func (r *Router) BotCache(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Add("Cache-Control", "max-age=3600") + next.ServeHTTP(w, req) + }) +} diff --git a/internal/db/bot_co_owners.sql.go b/internal/db/bot_co_owners.sql.go new file mode 100644 index 0000000..27129a1 --- /dev/null +++ b/internal/db/bot_co_owners.sql.go @@ -0,0 +1,138 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: bot_co_owners.sql + +package db + +import ( + "context" +) + +const addBotCoOwner = `-- name: AddBotCoOwner :exec +INSERT INTO bot_co_owners (bot_id, user_id) +VALUES ($1, $2) +ON CONFLICT DO NOTHING +` + +type AddBotCoOwnerParams struct { + BotID string `json:"bot_id"` + UserID string `json:"user_id"` +} + +func (q *Queries) AddBotCoOwner(ctx context.Context, arg AddBotCoOwnerParams) error { + _, err := q.db.Exec(ctx, addBotCoOwner, arg.BotID, arg.UserID) + return err +} + +const getBotCoOwner = `-- name: GetBotCoOwner :one +SELECT bot_id, user_id FROM bot_co_owners +WHERE bot_id = $1 AND user_id = $2 +` + +type GetBotCoOwnerParams struct { + BotID string `json:"bot_id"` + UserID string `json:"user_id"` +} + +func (q *Queries) GetBotCoOwner(ctx context.Context, arg GetBotCoOwnerParams) (*BotCoOwner, error) { + row := q.db.QueryRow(ctx, getBotCoOwner, arg.BotID, arg.UserID) + var i BotCoOwner + err := row.Scan(&i.BotID, &i.UserID) + return &i, err +} + +const listBotsByCoOwner = `-- name: ListBotsByCoOwner :many +SELECT b.id, b.username, b.avatar, b.overview, b.description, b.is_slash, b.install_context, b.guild_count, b.install_count, b.imported_from, b.prefix, b.created_at, b.updated_at, b.status, b.main_owner_id +FROM bot_co_owners bco +JOIN bots b ON b.id = bco.bot_id +WHERE bco.user_id = $1 +` + +func (q *Queries) ListBotsByCoOwner(ctx context.Context, userID string) ([]*Bot, error) { + rows, err := q.db.Query(ctx, listBotsByCoOwner, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Bot{} + for rows.Next() { + var i Bot + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCoOwnersByBot = `-- name: ListCoOwnersByBot :many +SELECT u.id, u.username, u.biography +FROM bot_co_owners bco +JOIN users u ON u.id = bco.user_id +WHERE bco.bot_id = $1 +` + +func (q *Queries) ListCoOwnersByBot(ctx context.Context, botID string) ([]*User, error) { + rows, err := q.db.Query(ctx, listCoOwnersByBot, botID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*User{} + for rows.Next() { + var i User + if err := rows.Scan(&i.ID, &i.Username, &i.Biography); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeAllCoOwnersByBot = `-- name: RemoveAllCoOwnersByBot :exec +DELETE FROM bot_co_owners +WHERE bot_id = $1 +` + +func (q *Queries) RemoveAllCoOwnersByBot(ctx context.Context, botID string) error { + _, err := q.db.Exec(ctx, removeAllCoOwnersByBot, botID) + return err +} + +const removeBotCoOwner = `-- name: RemoveBotCoOwner :exec +DELETE FROM bot_co_owners +WHERE bot_id = $1 AND user_id = $2 +` + +type RemoveBotCoOwnerParams struct { + BotID string `json:"bot_id"` + UserID string `json:"user_id"` +} + +func (q *Queries) RemoveBotCoOwner(ctx context.Context, arg RemoveBotCoOwnerParams) error { + _, err := q.db.Exec(ctx, removeBotCoOwner, arg.BotID, arg.UserID) + return err +} diff --git a/internal/db/bots.sql.go b/internal/db/bots.sql.go new file mode 100644 index 0000000..6d7fac1 --- /dev/null +++ b/internal/db/bots.sql.go @@ -0,0 +1,468 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: bots.sql + +package db + +import ( + "context" +) + +const countBotsByUsername = `-- name: CountBotsByUsername :one +SELECT COUNT(*) FROM bots +WHERE ($2::text IS NULL OR username ILIKE '%' || $2 || '%') AND status = $1 +` + +type CountBotsByUsernameParams struct { + Status BotStatus `json:"status"` + Query string `json:"query"` +} + +func (q *Queries) CountBotsByUsername(ctx context.Context, arg CountBotsByUsernameParams) (int64, error) { + row := q.db.QueryRow(ctx, countBotsByUsername, arg.Status, arg.Query) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createBot = `-- name: CreateBot :one +INSERT INTO bots ( + id, + username, + avatar, + overview, + description, + is_slash, + install_context, + guild_count, + install_count, + imported_from, + prefix, + main_owner_id +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +RETURNING id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id +` + +type CreateBotParams struct { + ID string `json:"id"` + Username string `json:"username"` + Avatar *string `json:"avatar"` + Overview *string `json:"overview"` + Description *string `json:"description"` + IsSlash bool `json:"is_slash"` + InstallContext InstallContext `json:"install_context"` + GuildCount *int32 `json:"guild_count"` + InstallCount *int32 `json:"install_count"` + ImportedFrom *string `json:"imported_from"` + Prefix *string `json:"prefix"` + MainOwnerID *string `json:"main_owner_id"` +} + +func (q *Queries) CreateBot(ctx context.Context, arg CreateBotParams) (*Bot, error) { + row := q.db.QueryRow(ctx, createBot, + arg.ID, + arg.Username, + arg.Avatar, + arg.Overview, + arg.Description, + arg.IsSlash, + arg.InstallContext, + arg.GuildCount, + arg.InstallCount, + arg.ImportedFrom, + arg.Prefix, + arg.MainOwnerID, + ) + var i Bot + err := row.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ) + return &i, err +} + +const deleteBot = `-- name: DeleteBot :exec +DELETE FROM bots +WHERE id = $1 +` + +func (q *Queries) DeleteBot(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, deleteBot, id) + return err +} + +const getBot = `-- name: GetBot :one +SELECT id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id FROM bots +WHERE id = $1 +` + +func (q *Queries) GetBot(ctx context.Context, id string) (*Bot, error) { + row := q.db.QueryRow(ctx, getBot, id) + var i Bot + err := row.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ) + return &i, err +} + +const getBotByUsername = `-- name: GetBotByUsername :one +SELECT id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id FROM bots +WHERE username = $1 +` + +func (q *Queries) GetBotByUsername(ctx context.Context, username string) (*Bot, error) { + row := q.db.QueryRow(ctx, getBotByUsername, username) + var i Bot + err := row.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ) + return &i, err +} + +const listBots = `-- name: ListBots :many +SELECT id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id FROM bots +ORDER BY id +LIMIT $1 +OFFSET $2 +` + +type ListBotsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListBots(ctx context.Context, arg ListBotsParams) ([]*Bot, error) { + rows, err := q.db.Query(ctx, listBots, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Bot{} + for rows.Next() { + var i Bot + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listBotsByOwner = `-- name: ListBotsByOwner :many +SELECT id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id FROM bots +WHERE main_owner_id = $1 +ORDER BY id +LIMIT $2 +OFFSET $3 +` + +type ListBotsByOwnerParams struct { + MainOwnerID *string `json:"main_owner_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListBotsByOwner(ctx context.Context, arg ListBotsByOwnerParams) ([]*Bot, error) { + rows, err := q.db.Query(ctx, listBotsByOwner, arg.MainOwnerID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Bot{} + for rows.Next() { + var i Bot + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listBotsByStatus = `-- name: ListBotsByStatus :many +SELECT id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id FROM bots +WHERE status = $1 +ORDER BY id +LIMIT $2 +OFFSET $3 +` + +type ListBotsByStatusParams struct { + Status BotStatus `json:"status"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListBotsByStatus(ctx context.Context, arg ListBotsByStatusParams) ([]*Bot, error) { + rows, err := q.db.Query(ctx, listBotsByStatus, arg.Status, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Bot{} + for rows.Next() { + var i Bot + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchBotsByUsername = `-- name: SearchBotsByUsername :many +SELECT id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id FROM bots +WHERE ($3::text IS NULL OR username ILIKE '%' || $3 || '%') +ORDER BY created_at DESC +LIMIT $1 +OFFSET $2 +` + +type SearchBotsByUsernameParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + Query string `json:"query"` +} + +func (q *Queries) SearchBotsByUsername(ctx context.Context, arg SearchBotsByUsernameParams) ([]*Bot, error) { + rows, err := q.db.Query(ctx, searchBotsByUsername, arg.Limit, arg.Offset, arg.Query) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Bot{} + for rows.Next() { + var i Bot + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateBot = `-- name: UpdateBot :one +UPDATE bots +SET + username = coalesce($1, username), + avatar = coalesce($2, avatar), + overview = coalesce($3, overview), + description = coalesce($4, description), + is_slash = coalesce($5, is_slash), + install_context = coalesce($6, install_context), + guild_count = coalesce($7, guild_count), + install_count = coalesce($8, install_count), + imported_from = coalesce($9, imported_from), + prefix = coalesce($10, prefix), + main_owner_id = coalesce($11, main_owner_id), + updated_at = now() +WHERE id = $12 +RETURNING id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id +` + +type UpdateBotParams struct { + Username *string `json:"username"` + Avatar *string `json:"avatar"` + Overview *string `json:"overview"` + Description *string `json:"description"` + IsSlash *bool `json:"is_slash"` + InstallContext InstallContext `json:"install_context"` + GuildCount *int32 `json:"guild_count"` + InstallCount *int32 `json:"install_count"` + ImportedFrom *string `json:"imported_from"` + Prefix *string `json:"prefix"` + MainOwnerID *string `json:"main_owner_id"` + ID string `json:"id"` +} + +func (q *Queries) UpdateBot(ctx context.Context, arg UpdateBotParams) (*Bot, error) { + row := q.db.QueryRow(ctx, updateBot, + arg.Username, + arg.Avatar, + arg.Overview, + arg.Description, + arg.IsSlash, + arg.InstallContext, + arg.GuildCount, + arg.InstallCount, + arg.ImportedFrom, + arg.Prefix, + arg.MainOwnerID, + arg.ID, + ) + var i Bot + err := row.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ) + return &i, err +} + +const updateBotStatus = `-- name: UpdateBotStatus :one +UPDATE bots +SET + status = $2, + updated_at = now() +WHERE id = $1 +RETURNING id, username, avatar, overview, description, is_slash, install_context, guild_count, install_count, imported_from, prefix, created_at, updated_at, status, main_owner_id +` + +type UpdateBotStatusParams struct { + ID string `json:"id"` + Status BotStatus `json:"status"` +} + +func (q *Queries) UpdateBotStatus(ctx context.Context, arg UpdateBotStatusParams) (*Bot, error) { + row := q.db.QueryRow(ctx, updateBotStatus, arg.ID, arg.Status) + var i Bot + err := row.Scan( + &i.ID, + &i.Username, + &i.Avatar, + &i.Overview, + &i.Description, + &i.IsSlash, + &i.InstallContext, + &i.GuildCount, + &i.InstallCount, + &i.ImportedFrom, + &i.Prefix, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.MainOwnerID, + ) + return &i, err +} diff --git a/internal/db/custom.go b/internal/db/custom.go new file mode 100644 index 0000000..1ad46a0 --- /dev/null +++ b/internal/db/custom.go @@ -0,0 +1,43 @@ +package db + +type InstallContext string + +const ( + InstallContextUser InstallContext = "user" + InstallContextGuild InstallContext = "guild" +) + +func (c InstallContext) String() string { + return string(c) +} + +func (c InstallContext) IsValid() bool { + switch c { + case InstallContextUser, InstallContextGuild: + return true + default: + return false + } +} + +type BotStatus string + +const ( + BotStatusDeleted BotStatus = "deleted" + BotStatusPending BotStatus = "pending" + BotStatusRejected BotStatus = "rejected" + BotStatusApproved BotStatus = "approved" +) + +func (s BotStatus) String() string { + return string(s) +} + +func (s BotStatus) IsValid() bool { + switch s { + case BotStatusDeleted, BotStatusPending, BotStatusRejected, BotStatusApproved: + return true + default: + return false + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..9d485b5 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..cde1bf1 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,44 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "time" +) + +type Bot struct { + ID string `json:"id"` + Username string `json:"username"` + Avatar *string `json:"avatar"` + Overview *string `json:"overview"` + Description *string `json:"description"` + IsSlash bool `json:"is_slash"` + InstallContext InstallContext `json:"install_context"` + GuildCount *int32 `json:"guild_count"` + InstallCount *int32 `json:"install_count"` + ImportedFrom *string `json:"imported_from"` + Prefix *string `json:"prefix"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + Status BotStatus `json:"status"` + MainOwnerID *string `json:"main_owner_id"` +} + +type BotCoOwner struct { + BotID string `json:"bot_id"` + UserID string `json:"user_id"` +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Biography *string `json:"biography"` +} + +type Vote struct { + UserID string `json:"user_id"` + BotID string `json:"bot_id"` + VotedAt time.Time `json:"voted_at"` +} diff --git a/internal/db/querier.go b/internal/db/querier.go new file mode 100644 index 0000000..4bb911e --- /dev/null +++ b/internal/db/querier.go @@ -0,0 +1,43 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" +) + +type Querier interface { + AddBotCoOwner(ctx context.Context, arg AddBotCoOwnerParams) error + CanVoteAgain(ctx context.Context, arg CanVoteAgainParams) (bool, error) + CountBotsByUsername(ctx context.Context, arg CountBotsByUsernameParams) (int64, error) + CountVotesByBot(ctx context.Context, botID string) (int64, error) + CreateBot(ctx context.Context, arg CreateBotParams) (*Bot, error) + CreateUser(ctx context.Context, arg CreateUserParams) (*User, error) + CreateVote(ctx context.Context, arg CreateVoteParams) (*Vote, error) + DeleteBot(ctx context.Context, id string) error + DeleteUser(ctx context.Context, id string) error + GetBot(ctx context.Context, id string) (*Bot, error) + GetBotByUsername(ctx context.Context, username string) (*Bot, error) + GetBotCoOwner(ctx context.Context, arg GetBotCoOwnerParams) (*BotCoOwner, error) + GetUser(ctx context.Context, id string) (*User, error) + GetUserByUsername(ctx context.Context, username string) (*User, error) + GetVote(ctx context.Context, arg GetVoteParams) (*Vote, error) + ListBots(ctx context.Context, arg ListBotsParams) ([]*Bot, error) + ListBotsByCoOwner(ctx context.Context, userID string) ([]*Bot, error) + ListBotsByOwner(ctx context.Context, arg ListBotsByOwnerParams) ([]*Bot, error) + ListBotsByStatus(ctx context.Context, arg ListBotsByStatusParams) ([]*Bot, error) + ListCoOwnersByBot(ctx context.Context, botID string) ([]*User, error) + ListUsers(ctx context.Context) ([]*User, error) + ListVotesByBot(ctx context.Context, botID string) ([]*Vote, error) + ListVotesByUser(ctx context.Context, userID string) ([]*Vote, error) + RemoveAllCoOwnersByBot(ctx context.Context, botID string) error + RemoveBotCoOwner(ctx context.Context, arg RemoveBotCoOwnerParams) error + SearchBotsByUsername(ctx context.Context, arg SearchBotsByUsernameParams) ([]*Bot, error) + UpdateBot(ctx context.Context, arg UpdateBotParams) (*Bot, error) + UpdateBotStatus(ctx context.Context, arg UpdateBotStatusParams) (*Bot, error) + UpdateUser(ctx context.Context, arg UpdateUserParams) (*User, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/sql/migrations/20260323184653_init.sql b/internal/db/sql/migrations/20260323184653_init.sql new file mode 100644 index 0000000..ed1dcd0 --- /dev/null +++ b/internal/db/sql/migrations/20260323184653_init.sql @@ -0,0 +1,62 @@ +-- +goose Up +-- +goose StatementBegin +create table users ( + id text primary key, + username text not null, + biography text +); + +create table bots ( + id text primary key, + username text not null, + avatar text, + overview text, + description text, + is_slash boolean not null, + install_context text check (install_context in ('guild', 'user')) not null, + guild_count int, + install_count int, + imported_from text, + prefix text, + created_at timestamp with time zone default now(), + updated_at timestamp with time zone default now(), + status text check ( + status in ('deleted', 'pending', 'rejected', 'approved') + ) default 'pending', + main_owner_id text references users (id) +); + +create table bot_co_owners ( + bot_id text references bots (id), + user_id text references users (id), + primary key (bot_id, user_id) +); + +create table votes ( + user_id text references users (id), + bot_id text references bots (id), + voted_at timestamp with time zone default now(), + primary key (user_id, bot_id, voted_at) +); + +create +or replace function can_vote_again (user_id text, bot_id text) returns boolean as $$ +BEGIN + RETURN NOT EXISTS ( + SELECT 1 FROM votes + WHERE user_id = $1 AND bot_id = $2 AND voted_at > now() - interval '12 hours' + ); +END; +$$ language plpgsql; +-- +goose StatementEnd + + +-- +goose Down +-- +goose StatementBegin +drop function if exists can_vote_again; + +drop table if exists votes; +drop table if exists bot_co_owners; +drop table if exists bots; +drop table if exists users; +-- +goose StatementEnd diff --git a/internal/db/sql/queries/bot_co_owners.sql b/internal/db/sql/queries/bot_co_owners.sql new file mode 100644 index 0000000..5d0c23c --- /dev/null +++ b/internal/db/sql/queries/bot_co_owners.sql @@ -0,0 +1,28 @@ +-- name: GetBotCoOwner :one +SELECT * FROM bot_co_owners +WHERE bot_id = $1 AND user_id = $2; + +-- name: ListCoOwnersByBot :many +SELECT u.* +FROM bot_co_owners bco +JOIN users u ON u.id = bco.user_id +WHERE bco.bot_id = $1; + +-- name: ListBotsByCoOwner :many +SELECT b.* +FROM bot_co_owners bco +JOIN bots b ON b.id = bco.bot_id +WHERE bco.user_id = $1; + +-- name: AddBotCoOwner :exec +INSERT INTO bot_co_owners (bot_id, user_id) +VALUES ($1, $2) +ON CONFLICT DO NOTHING; + +-- name: RemoveBotCoOwner :exec +DELETE FROM bot_co_owners +WHERE bot_id = $1 AND user_id = $2; + +-- name: RemoveAllCoOwnersByBot :exec +DELETE FROM bot_co_owners +WHERE bot_id = $1; diff --git a/internal/db/sql/queries/bots.sql b/internal/db/sql/queries/bots.sql new file mode 100644 index 0000000..6e8b5b3 --- /dev/null +++ b/internal/db/sql/queries/bots.sql @@ -0,0 +1,86 @@ +-- name: GetBot :one +SELECT * FROM bots +WHERE id = $1; + +-- name: GetBotByUsername :one +SELECT * FROM bots +WHERE username = $1; + +-- name: CreateBot :one +INSERT INTO bots ( + id, + username, + avatar, + overview, + description, + is_slash, + install_context, + guild_count, + install_count, + imported_from, + prefix, + main_owner_id +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +RETURNING *; + +-- name: UpdateBot :one +UPDATE bots +SET + username = coalesce(sqlc.narg('username'), username), + avatar = coalesce(sqlc.narg('avatar'), avatar), + overview = coalesce(sqlc.narg('overview'), overview), + description = coalesce(sqlc.narg('description'), description), + is_slash = coalesce(sqlc.narg('is_slash'), is_slash), + install_context = coalesce(sqlc.narg('install_context'), install_context), + guild_count = coalesce(sqlc.narg('guild_count'), guild_count), + install_count = coalesce(sqlc.narg('install_count'), install_count), + imported_from = coalesce(sqlc.narg('imported_from'), imported_from), + prefix = coalesce(sqlc.narg('prefix'), prefix), + main_owner_id = coalesce(sqlc.narg('main_owner_id'), main_owner_id), + updated_at = now() +WHERE id = sqlc.arg('id') +RETURNING *; + +-- name: UpdateBotStatus :one +UPDATE bots +SET + status = $2, + updated_at = now() +WHERE id = $1 +RETURNING *; + +-- name: CountBotsByUsername :one +SELECT COUNT(*) FROM bots +WHERE (@query::text IS NULL OR username ILIKE '%' || @query || '%') AND status = $1; + +-- name: SearchBotsByUsername :many +SELECT * FROM bots +WHERE (@query::text IS NULL OR username ILIKE '%' || @query || '%') +ORDER BY created_at DESC +LIMIT $1 +OFFSET $2; + +-- name: ListBots :many +SELECT * FROM bots +ORDER BY id +LIMIT $1 +OFFSET $2; + +-- name: ListBotsByStatus :many +SELECT * FROM bots +WHERE status = $1 +ORDER BY id +LIMIT $2 +OFFSET $3; + +-- name: ListBotsByOwner :many +SELECT * FROM bots +WHERE main_owner_id = $1 +ORDER BY id +LIMIT $2 +OFFSET $3; + +-- name: DeleteBot :exec +DELETE FROM bots +WHERE id = $1; diff --git a/internal/db/sql/queries/users.sql b/internal/db/sql/queries/users.sql new file mode 100644 index 0000000..f299d07 --- /dev/null +++ b/internal/db/sql/queries/users.sql @@ -0,0 +1,28 @@ +-- name: GetUser :one +SELECT * FROM users +WHERE id = $1; + +-- name: GetUserByUsername :one +SELECT * FROM users +WHERE username = $1; + +-- name: ListUsers :many +SELECT * FROM users +ORDER BY id; + +-- name: CreateUser :one +INSERT INTO users (id, username, biography) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: UpdateUser :one +UPDATE users +SET + username = coalesce(sqlc.narg('username'), username), + biography = coalesce(sqlc.narg('biography'), biography) +WHERE id = sqlc.arg('id') +RETURNING *; + +-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1; diff --git a/internal/db/sql/queries/votes.sql b/internal/db/sql/queries/votes.sql new file mode 100644 index 0000000..b69a82d --- /dev/null +++ b/internal/db/sql/queries/votes.sql @@ -0,0 +1,27 @@ +-- name: GetVote :one +SELECT * FROM votes +WHERE user_id = $1 AND bot_id = $2 +ORDER BY voted_at DESC +LIMIT 1; + +-- name: ListVotesByBot :many +SELECT * FROM votes +WHERE bot_id = $1 +ORDER BY voted_at DESC; + +-- name: ListVotesByUser :many +SELECT * FROM votes +WHERE user_id = $1 +ORDER BY voted_at DESC; + +-- name: CountVotesByBot :one +SELECT count(*) FROM votes +WHERE bot_id = $1; + +-- name: CreateVote :one +INSERT INTO votes (user_id, bot_id) +VALUES ($1, $2) +RETURNING *; + +-- name: CanVoteAgain :one +SELECT can_vote_again($1, $2); diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go new file mode 100644 index 0000000..48e7030 --- /dev/null +++ b/internal/db/users.sql.go @@ -0,0 +1,110 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package db + +import ( + "context" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (id, username, biography) +VALUES ($1, $2, $3) +RETURNING id, username, biography +` + +type CreateUserParams struct { + ID string `json:"id"` + Username string `json:"username"` + Biography *string `json:"biography"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (*User, error) { + row := q.db.QueryRow(ctx, createUser, arg.ID, arg.Username, arg.Biography) + var i User + err := row.Scan(&i.ID, &i.Username, &i.Biography) + return &i, err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1 +` + +func (q *Queries) DeleteUser(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, deleteUser, id) + return err +} + +const getUser = `-- name: GetUser :one +SELECT id, username, biography FROM users +WHERE id = $1 +` + +func (q *Queries) GetUser(ctx context.Context, id string) (*User, error) { + row := q.db.QueryRow(ctx, getUser, id) + var i User + err := row.Scan(&i.ID, &i.Username, &i.Biography) + return &i, err +} + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, biography FROM users +WHERE username = $1 +` + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (*User, error) { + row := q.db.QueryRow(ctx, getUserByUsername, username) + var i User + err := row.Scan(&i.ID, &i.Username, &i.Biography) + return &i, err +} + +const listUsers = `-- name: ListUsers :many +SELECT id, username, biography FROM users +ORDER BY id +` + +func (q *Queries) ListUsers(ctx context.Context) ([]*User, error) { + rows, err := q.db.Query(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*User{} + for rows.Next() { + var i User + if err := rows.Scan(&i.ID, &i.Username, &i.Biography); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUser = `-- name: UpdateUser :one +UPDATE users +SET + username = coalesce($1, username), + biography = coalesce($2, biography) +WHERE id = $3 +RETURNING id, username, biography +` + +type UpdateUserParams struct { + Username *string `json:"username"` + Biography *string `json:"biography"` + ID string `json:"id"` +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (*User, error) { + row := q.db.QueryRow(ctx, updateUser, arg.Username, arg.Biography, arg.ID) + var i User + err := row.Scan(&i.ID, &i.Username, &i.Biography) + return &i, err +} diff --git a/internal/db/votes.sql.go b/internal/db/votes.sql.go new file mode 100644 index 0000000..4f5e10e --- /dev/null +++ b/internal/db/votes.sql.go @@ -0,0 +1,127 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: votes.sql + +package db + +import ( + "context" +) + +const canVoteAgain = `-- name: CanVoteAgain :one +SELECT can_vote_again($1, $2) +` + +type CanVoteAgainParams struct { + UserID string `json:"user_id"` + BotID string `json:"bot_id"` +} + +func (q *Queries) CanVoteAgain(ctx context.Context, arg CanVoteAgainParams) (bool, error) { + row := q.db.QueryRow(ctx, canVoteAgain, arg.UserID, arg.BotID) + var can_vote_again bool + err := row.Scan(&can_vote_again) + return can_vote_again, err +} + +const countVotesByBot = `-- name: CountVotesByBot :one +SELECT count(*) FROM votes +WHERE bot_id = $1 +` + +func (q *Queries) CountVotesByBot(ctx context.Context, botID string) (int64, error) { + row := q.db.QueryRow(ctx, countVotesByBot, botID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createVote = `-- name: CreateVote :one +INSERT INTO votes (user_id, bot_id) +VALUES ($1, $2) +RETURNING user_id, bot_id, voted_at +` + +type CreateVoteParams struct { + UserID string `json:"user_id"` + BotID string `json:"bot_id"` +} + +func (q *Queries) CreateVote(ctx context.Context, arg CreateVoteParams) (*Vote, error) { + row := q.db.QueryRow(ctx, createVote, arg.UserID, arg.BotID) + var i Vote + err := row.Scan(&i.UserID, &i.BotID, &i.VotedAt) + return &i, err +} + +const getVote = `-- name: GetVote :one +SELECT user_id, bot_id, voted_at FROM votes +WHERE user_id = $1 AND bot_id = $2 +ORDER BY voted_at DESC +LIMIT 1 +` + +type GetVoteParams struct { + UserID string `json:"user_id"` + BotID string `json:"bot_id"` +} + +func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (*Vote, error) { + row := q.db.QueryRow(ctx, getVote, arg.UserID, arg.BotID) + var i Vote + err := row.Scan(&i.UserID, &i.BotID, &i.VotedAt) + return &i, err +} + +const listVotesByBot = `-- name: ListVotesByBot :many +SELECT user_id, bot_id, voted_at FROM votes +WHERE bot_id = $1 +ORDER BY voted_at DESC +` + +func (q *Queries) ListVotesByBot(ctx context.Context, botID string) ([]*Vote, error) { + rows, err := q.db.Query(ctx, listVotesByBot, botID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Vote{} + for rows.Next() { + var i Vote + if err := rows.Scan(&i.UserID, &i.BotID, &i.VotedAt); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listVotesByUser = `-- name: ListVotesByUser :many +SELECT user_id, bot_id, voted_at FROM votes +WHERE user_id = $1 +ORDER BY voted_at DESC +` + +func (q *Queries) ListVotesByUser(ctx context.Context, userID string) ([]*Vote, error) { + rows, err := q.db.Query(ctx, listVotesByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Vote{} + for rows.Next() { + var i Vote + if err := rows.Scan(&i.UserID, &i.BotID, &i.VotedAt); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/errorutil/errorutil.go b/internal/errorutil/errorutil.go new file mode 100644 index 0000000..3bc34a6 --- /dev/null +++ b/internal/errorutil/errorutil.go @@ -0,0 +1,62 @@ +package errorutil + +import ( + "errors" + "net/http" + + "github.com/go-chi/render" +) + +var ( + ErrBotAlreadyExists = errors.New("this bot has already been submitted") + ErrSearchFailed = errors.New("no bots found fitting this filter") + ErrInvalidID = errors.New("invalid discord id") + ErrMainOwnerAsCoOwner = errors.New("you cannot set yourself as a co-owner") +) + +type ErrResponse struct { + Err error `json:"-"` + HTTPStatusCode int `json:"-"` + StatusText string `json:"status"` + ErrorText string `json:"error,omitempty"` +} + +func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error { + render.Status(r, e.HTTPStatusCode) + return nil +} + +func ErrInvalidRequest(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: 400, + StatusText: "invalid request", + ErrorText: errString(err), + } +} + +func ErrInternal(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: 500, + StatusText: "internal server error", + ErrorText: errString(err), + } +} + +var ErrNotFound = &ErrResponse{ + HTTPStatusCode: 404, + StatusText: "resource not found", +} + +var ErrUnauthorized = &ErrResponse{ + HTTPStatusCode: 401, + StatusText: "unauthorized", +} + +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..b6975cf --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + + "codeberg.org/nextgo/dbots/internal/db" + "codeberg.org/nextgo/dbots/internal/errorutil" + "github.com/go-chi/render" +) + +type contextKey string + +var userKey contextKey = "user" +var mainUserID = "774287684944134155" + +// AuthMiddleware is a middleware to set the user as context value. +// this middleware does not prevents the user from accessing the route +// if not authorized. +func AuthMiddleware(q *db.Queries) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, err := q.GetUser(r.Context(), mainUserID) + if err != nil { + user, err = q.CreateUser(r.Context(), db.CreateUserParams{ + ID: mainUserID, + Username: "elisiei", + }) + if err != nil { + slog.Error("eeeehhh", "err", err) + } + } + + ctx := context.WithValue(r.Context(), userKey, user) // mocked + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// AuthGuardMiddleware is a middleware that prevents the user +// from accessing the route if they are NOT authorized. +func AuthGuardMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, ok := r.Context().Value(userKey).(*db.User) + if !ok { + render.Render(w, r, errorutil.ErrUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func GetUser(ctx context.Context) *db.User { + user := ctx.Value(userKey).(*db.User) + return user +} diff --git a/internal/paginate/paginate.go b/internal/paginate/paginate.go new file mode 100644 index 0000000..314a419 --- /dev/null +++ b/internal/paginate/paginate.go @@ -0,0 +1,87 @@ +package paginate + +import ( + "net/http" + "strconv" +) + +const ( + DefaultLimit = 20 + MaxLimit = 100 +) + +// PageInfo holds pagination metadata returned alongside the data. +type PageInfo struct { + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` +} + +// Page is the generic paginated response envelope. +type Page[T any] struct { + Data []T `json:"data"` + Pagination PageInfo `json:"pagination"` +} + +// NewPage constructs a Page from a slice already fetched from the DB +// (which owns the limit/offset), the total record count, and the Params +// that were passed to the query. +func NewPage[T any](data []T, total int, p Params) Page[T] { + if data == nil { + data = []T{} + } + return Page[T]{ + Data: data, + Pagination: PageInfo{ + Total: total, + Limit: int(p.Limit), + Offset: int(p.Offset), + HasNext: int(p.Offset)+int(p.Limit) < total, + HasPrev: p.Offset > 0, + }, + } +} + +// Params holds parsed limit/offset values +type Params struct { + Limit int32 + Offset int32 +} + +// ParseParams reads "limit" and "offset" from the request query string and +// returns safe, clamped values. Invalid or missing values fall back to defaults. +// +// GET /bots?limit=10&offset=30 +func ParseParams(r *http.Request) Params { + limit := parseIntParam(r, "limit", DefaultLimit) + offset := parseIntParam(r, "offset", 0) + + if limit <= 0 { + limit = DefaultLimit + } + if limit > MaxLimit { + limit = MaxLimit + } + if offset < 0 { + offset = 0 + } + + return Params{ + Limit: int32(limit), + Offset: int32(offset), + } +} + +func parseIntParam(r *http.Request, key string, fallback int) int { + raw := r.URL.Query().Get(key) + if raw == "" { + return fallback + } + v, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return v +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..6799d48 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,55 @@ +package server + +import ( + "fmt" + "log/slog" + "net/http" + "os" + + "codeberg.org/nextgo/dbots/internal/admin" + "codeberg.org/nextgo/dbots/internal/bot" + "codeberg.org/nextgo/dbots/internal/db" + customMiddlewares "codeberg.org/nextgo/dbots/internal/middleware" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httplog/v3" +) + +type Server struct { + router *chi.Mux + queries *db.Queries +} + +func NewServer(queries *db.Queries, logger *slog.Logger) *Server { + router := chi.NewMux() + + router.Use(httplog.RequestLogger(logger, nil)) + router.Use(middleware.Recoverer) + router.Use(middleware.RequestID) + router.Use(middleware.RealIP) + router.Use(middleware.Recoverer) + router.Use(customMiddlewares.AuthMiddleware(queries)) + + return &Server{ + router: router, + queries: queries, + } +} + +func (s *Server) Register() { + botRouter := bot.NewRouter(s.queries) + adminRouter := admin.NewRouter(s.queries) + + s.router.Mount("/bots", botRouter.Routes()) + s.router.Mount("/admin", adminRouter.Routes()) +} + +func (s *Server) Start(port int) { + s.Register() + slog.Info("server started", "port", port) + if err := http.ListenAndServe(fmt.Sprintf(":%d", port), s.router); err != nil { + slog.Error("error starting server", "port", port, "err", err) + os.Exit(1) + } +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..da17ad1 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,38 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "internal/db/sql/queries/*.sql" + schema: "internal/db/sql/migrations" + gen: + go: + package: "db" + out: "internal/db/" + sql_package: "pgx/v5" + emit_interface: true + emit_json_tags: true + emit_empty_slices: true + emit_enum_valid_method: true + emit_all_enum_values: true + emit_prepared_queries: true + emit_pointers_for_null_types: true + omit_unused_structs: true + emit_result_struct_pointers: true + overrides: + - db_type: "pg_catalog.timestamptz" + nullable: false + go_type: + import: "time" + type: "Time" + + - db_type: "pg_catalog.timestamptz" + nullable: true + go_type: + import: "time" + type: "Time" + pointer: true + - column: "bots.install_context" + go_type: + type: "InstallContext" + - column: "bots.status" + go_type: + type: "BotStatus"