chore: base

This commit is contained in:
Elisiei Yehorov 2026-04-17 22:05:05 +02:00
parent 3ad879c867
commit 1062fc7c68
Signed by: elisiei
GPG key ID: BA1D158DCE3DF089
30 changed files with 2059 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

33
.gitignore vendored Normal file
View file

@ -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/

46
cmd/server/main.go Normal file
View file

@ -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)
}

25
flake.lock generated Normal file
View file

@ -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
}

47
flake.nix Normal file
View file

@ -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
];
};
}
);
};
}

21
go.mod Normal file
View file

@ -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
)

38
go.sum Normal file
View file

@ -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=

42
internal/admin/admin.go Normal file
View file

@ -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
}

46
internal/admin/router.go Normal file
View file

@ -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)
}

85
internal/bot/bot.go Normal file
View file

@ -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
}

30
internal/bot/input.go Normal file
View file

@ -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
}

110
internal/bot/router.go Normal file
View file

@ -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)
})
}

View file

@ -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
}

468
internal/db/bots.sql.go Normal file
View file

@ -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
}

43
internal/db/custom.go Normal file
View file

@ -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
}
}

32
internal/db/db.go Normal file
View file

@ -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,
}
}

44
internal/db/models.go Normal file
View file

@ -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"`
}

43
internal/db/querier.go Normal file
View file

@ -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)

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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);

110
internal/db/users.sql.go Normal file
View file

@ -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
}

127
internal/db/votes.sql.go Normal file
View file

@ -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
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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
}

55
internal/server/server.go Normal file
View file

@ -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)
}
}

38
sqlc.yaml Normal file
View file

@ -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"