diff --git a/README.md b/README.md index 996a389..06e262f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,33 @@ # api.acmcsuf.com ACM at CSUF club API for managing events, announcements, forms, and other services! + +## Develop + +### Start API server + +```sh +go run cmd/api/main.go +``` + +### Generate code + +```sh +go generate ./... +``` + +### Run tests + +```sh +go test ./... +``` + +### Format code + +```sh +go fmt ./... +``` + +--- + +Developed with 💚 by [**@acmcsufoss**](https://github.com/acmcsufoss) diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..ce4fc73 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/acmcsufoss/api.acmcsuf.com/internal/api" + "github.com/acmcsufoss/api.acmcsuf.com/internal/db/sqlite" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Set up signal handling for graceful shutdown. + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-signalChan + log.Println("Shutting down the server...") + cancel() + }() + + // Set up the database connection. + uri, ok := os.LookupEnv("DATABASE_URL") + if !ok { + log.Fatal("DATABASE_URL must be set") + } + + d, err := sql.Open("sqlite", uri) + if err != nil { + log.Fatalf("Error opening SQLite database: %v", err) + } + + // if err := sqlite.Migrate(ctx, db); err != nil { + // return nil, errors.Wrap(err, "cannot migrate sqlite db") + // } + // + // return sqliteStore{ + // q: sqlite.New(db), + // db: db, + // ctx: ctx, + // }, nil + + q := sqlite.New(d) + if err != nil { + log.Fatalf("Error creating SQLite store: %v", err) + } + defer func() { + if err := d.Close(); err != nil { + log.Fatalf("Error closing SQLite store: %v", err) + } + }() + + // Initialize and start the HTTP server. + handler := api.New(q) + port, ok := os.LookupEnv("PORT") + if !ok { + port = "8080" + } + + serverAddr := fmt.Sprintf(":%s", port) + go func() { + fmt.Printf("Server started on http://127.0.0.1%s\n", serverAddr) + if err := http.ListenAndServe(serverAddr, handler); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }() + + // Wait for shutdown signal. + <-ctx.Done() + + log.Println("Server shut down.") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..64d5256 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/acmcsufoss/api.acmcsuf.com + +go 1.21.3 + +require ( + github.com/pkg/errors v0.9.1 + github.com/swaggest/openapi-go v0.2.45 + github.com/swaggest/rest v0.2.61 + github.com/swaggest/swgui v1.8.0 + github.com/swaggest/usecase v1.3.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/go-chi/chi/v5 v5.0.10 // indirect + github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 // indirect + github.com/swaggest/form/v5 v5.1.1 // indirect + github.com/swaggest/jsonschema-go v0.3.64 // indirect + github.com/swaggest/refl v1.3.0 // indirect + github.com/vearutop/statigz v1.4.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0790b0b --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/dev v0.2.32 h1:DRZtloaoH1Igky3zphaUHV9+SLIV2H3lsf78JsJHFg0= +github.com/bool64/dev v0.2.32/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= +github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/santhosh-tekuri/jsonschema/v3 v3.1.0 h1:levPcBfnazlA1CyCMC3asL/QLZkq9pa8tQZOH513zQw= +github.com/santhosh-tekuri/jsonschema/v3 v3.1.0/go.mod h1:8kzK2TC0k0YjOForaAHdNEa7ik0fokNa2k30BKJ/W7Y= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY= +github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg= +github.com/swaggest/jsonschema-go v0.3.64 h1:HyB41fkA4XP0BZkqWfGap5i2JtRHQGXG/21dGDPbyLM= +github.com/swaggest/jsonschema-go v0.3.64/go.mod h1:DYuKqdpms/edvywsX6p1zHXCZkdwB28wRaBdFCe3Duw= +github.com/swaggest/openapi-go v0.2.45 h1:LOMAEleKVLg4E86lSCyioJK7ltjWRx50AaP4LZIbJ+Q= +github.com/swaggest/openapi-go v0.2.45/go.mod h1:/ykzNtS1ZO7X43OnEtyisMktxCiawQLyGd08rkjV68U= +github.com/swaggest/refl v1.3.0 h1:PEUWIku+ZznYfsoyheF97ypSduvMApYyGkYF3nabS0I= +github.com/swaggest/refl v1.3.0/go.mod h1:3Ujvbmh1pfSbDYjC6JGG7nMgPvpG0ehQL4iNonnLNbg= +github.com/swaggest/rest v0.2.61 h1:K2cc3yGTX5i6dkxszS8lH9DmRlqDRl2XWaRxxBpP/Jk= +github.com/swaggest/rest v0.2.61/go.mod h1:nQGcwz5pD3LGxMXAz0swBTSFHxAWgAh3QnMh7Q4lRvo= +github.com/swaggest/swgui v1.8.0 h1:dPu8TsYIOraaObAkyNdoiLI8mu7nOqQ6SU7HOv254rM= +github.com/swaggest/swgui v1.8.0/go.mod h1:YBaAVAwS3ndfvdtW8A4yWDJpge+W57y+8kW+f/DqZtU= +github.com/swaggest/usecase v1.3.1 h1:JdKV30MTSsDxAXxkldLNcEn8O2uf565khyo6gr5sS+w= +github.com/swaggest/usecase v1.3.1/go.mod h1:cae3lDd5VDmM36OQcOOOdAlEDg40TiQYIp99S9ejWqA= +github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU= +github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..138416b --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,51 @@ +package api + +import ( + "github.com/swaggest/openapi-go/openapi3" + "github.com/swaggest/rest/response/gzip" + "github.com/swaggest/rest/web" + swgui "github.com/swaggest/swgui/v5emb" + + "github.com/acmcsufoss/api.acmcsuf.com/internal/api/services" + "github.com/acmcsufoss/api.acmcsuf.com/internal/api/services/events" + "github.com/acmcsufoss/api.acmcsuf.com/internal/api/services/resources" + "github.com/acmcsufoss/api.acmcsuf.com/internal/db/sqlite" +) + +func New(q *sqlite.Queries) *web.Service { + s := web.NewService(openapi3.NewReflector()) + + // Init API documentation schema. + s.OpenAPISchema().SetTitle("Basic Example") + s.OpenAPISchema().SetDescription("This app showcases a trivial REST API.") + s.OpenAPISchema().SetVersion("v0.0.1") + + // Setup middlewares. + s.Wrap( + gzip.Middleware, // Response compression with support for direct gzip pass through. + ) + + // Add use case handler to router. + // s.Get("/hello/{name}", helloWorld()) + useAll(s, q) + + // Swagger UI endpoint at /docs. + s.Docs("/docs", swgui.New) + + return s +} + +func useAll(s *web.Service, q *sqlite.Queries) { + use("/resources", resources.New(q), s) + use("/events", events.New(q), s) +} + +func use(path string, s services.Service, ss *web.Service) { + ss.Get(path, s.Resources()) + ss.Post(path, s.PostResources()) + ss.Post(path, s.BatchPostResources()) + ss.Get(path+"/{id}", s.Resource()) + ss.Post(path+"/{id}", s.PostResource()) + ss.Post(path+"/{id}", s.BatchPostResource()) + ss.Delete(path+"/{id}", s.DeleteResource()) +} diff --git a/internal/api/services/events/service.go b/internal/api/services/events/service.go new file mode 100644 index 0000000..d59fbad --- /dev/null +++ b/internal/api/services/events/service.go @@ -0,0 +1,47 @@ +package events + +import ( + "github.com/swaggest/usecase" + + "github.com/acmcsufoss/api.acmcsuf.com/internal/api/services" + "github.com/acmcsufoss/api.acmcsuf.com/internal/db/sqlite" +) + +var _ services.Service = EventsService{} + +type EventsService struct { + q *sqlite.Queries +} + +func New(q *sqlite.Queries) *EventsService { + return &EventsService{q} +} + +func (s EventsService) Resources() usecase.IOInteractor { + panic("implement me") + // s.q.GetResourceList(context.TODO(), "") +} + +func (s EventsService) PostResources() usecase.IOInteractor { + panic("implement me") +} + +func (s EventsService) BatchPostResources() usecase.IOInteractor { + panic("implement me") +} + +func (s EventsService) Resource() usecase.IOInteractor { + panic("implement me") +} + +func (s EventsService) PostResource() usecase.IOInteractor { + panic("implement me") +} + +func (s EventsService) BatchPostResource() usecase.IOInteractor { + panic("implement me") +} + +func (s EventsService) DeleteResource() usecase.IOInteractor { + panic("implement me") +} diff --git a/internal/api/services/interfaces.go b/internal/api/services/interfaces.go new file mode 100644 index 0000000..b443290 --- /dev/null +++ b/internal/api/services/interfaces.go @@ -0,0 +1,29 @@ +package services + +import ( + "github.com/swaggest/usecase" +) + +// Service is the interface of API endpoints for a resource service. +type Service interface { + // Resources gets a list of paginated resource resources. + Resources() usecase.IOInteractor + + // PostResources creates a new resource resource. + PostResources() usecase.IOInteractor + + // BatchPostResources creates multiple new resource resources. + BatchPostResources() usecase.IOInteractor + + // Resource gets a single resource resource. + Resource() usecase.IOInteractor + + // PostResource creates a new resource resource. + PostResource() usecase.IOInteractor + + // BatchPostResource creates multiple new resource resources. + BatchPostResource() usecase.IOInteractor + + // DeleteResource deletes a single resource resource. + DeleteResource() usecase.IOInteractor +} diff --git a/internal/api/services/resources/service.go b/internal/api/services/resources/service.go new file mode 100644 index 0000000..13fd092 --- /dev/null +++ b/internal/api/services/resources/service.go @@ -0,0 +1,75 @@ +package resources + +import ( + "context" + + "github.com/swaggest/usecase" + "github.com/swaggest/usecase/status" + + "github.com/acmcsufoss/api.acmcsuf.com/internal/api/services" + "github.com/acmcsufoss/api.acmcsuf.com/internal/db/sqlite" +) + +var _ services.Service = ResourcesService{} + +type ResourcesService struct { + q *sqlite.Queries +} + +func New(q *sqlite.Queries) *ResourcesService { + return &ResourcesService{q} +} + +type resourceInput struct { + Title string `json:"title"` + ContentMd string `json:"content_md"` + ImageUrl string `json:"image_url"` + ResourceType string `json:"resource_type"` + ResourceListID string `json:"resource_list_id"` +} + +type resourceOutput sqlite.Resource + +func (s ResourcesService) Resources() usecase.IOInteractor { + panic("implement me") +} + +func (s ResourcesService) PostResources() usecase.IOInteractor { + panic("implement me") +} + +func (s ResourcesService) BatchPostResources() usecase.IOInteractor { + panic("implement me") +} + +func (s ResourcesService) Resource() usecase.IOInteractor { + // Create use case interactor with references to input/output types and interaction function. + u := usecase.NewIOI(new(resourceInput), new(resourceOutput), func(ctx context.Context, input, output interface{}) error { + // var ( + // in = input.(*resourceInput) + // out = output.(*resourceOutput) + // ) + + // TODO: Get resource by ID from database. + + return nil + }) + + // Describe use case interactor. + u.SetTitle("GetResource") + u.SetDescription("Gets a single base resource.") + u.SetExpectedErrors(status.InvalidArgument) + return u +} + +func (s ResourcesService) PostResource() usecase.IOInteractor { + panic("implement me") +} + +func (s ResourcesService) BatchPostResource() usecase.IOInteractor { + panic("implement me") +} + +func (s ResourcesService) DeleteResource() usecase.IOInteractor { + panic("implement me") +} diff --git a/internal/db/gen.go b/internal/db/gen.go new file mode 100644 index 0000000..79526fe --- /dev/null +++ b/internal/db/gen.go @@ -0,0 +1,3 @@ +package db + +//go:generate sqlc generate diff --git a/internal/db/sqlc.yaml b/internal/db/sqlc.yaml new file mode 100644 index 0000000..c9ed3fa --- /dev/null +++ b/internal/db/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "sqlite" + schema: "sqlite/schema.sql" + queries: "sqlite/queries.sql" + gen: + go: + package: sqlite + out: ./sqlite + json_tags_id_uppercase: true + emit_json_tags: true + output_models_file_name: ./schema.go diff --git a/internal/db/sqlite/db.go b/internal/db/sqlite/db.go new file mode 100644 index 0000000..bf52be0 --- /dev/null +++ b/internal/db/sqlite/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.23.0 + +package sqlite + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/sqlite/queries.sql b/internal/db/sqlite/queries.sql new file mode 100644 index 0000000..f4f5a49 --- /dev/null +++ b/internal/db/sqlite/queries.sql @@ -0,0 +1,68 @@ +-- name: CreateResource :exec +INSERT INTO resources (id, title, content_md, image_url, resource_type, resource_list_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?); + +-- name: CreateResourceList :exec +INSERT INTO resource_lists (title, created_at, updated_at) VALUES (?, ?, ?); + +-- name: CreateResourceReference :exec +INSERT INTO resource_references (resource_id, resource_list_id, created_at, updated_at) VALUES (?, ?, ?, ?); + +-- name: GetResourceList :many +SELECT rr.resource_id, rr.resource_list_id, rr.created_at, rr.updated_at +FROM resource_references rr +JOIN resources r ON rr.resource_id = r.id +JOIN resource_lists rl ON rr.resource_list_id = rl.id +WHERE rl.id = ? +ORDER BY rr.index_in_list ASC; + +-- name: AddResource :exec +INSERT INTO resource_references (resource_id, resource_list_id, index_in_list, created_at, updated_at) VALUES (?, ?, ?, ?, ?); + +-- name: DeleteResource :exec +DELETE FROM resources WHERE id = ?; + +-- name: CreateEvent :exec +INSERT INTO events (id, location, start_at, duration_ms, is_all_day, host, visibility) VALUES (?, ?, ?, ?, ?, ?, ?); + +-- name: GetEvent :one +SELECT + r.id, + r.title, + r.content_md, + r.image_url, + r.resource_type, + r.resource_list_id, + r.created_at, + r.updated_at, + e.location, + e.start_at, + e.duration_ms, + e.is_all_day, + e.host, + e.visibility +FROM resources r +INNER JOIN events e ON r.id = e.id +WHERE r.id = ?; + +-- name: CreateAnnouncement :exec +INSERT INTO announcements (id, event_list_id, approved_by_list_id, visibility, announce_at, discord_channel_id, discord_message_id) VALUES (?, ?, ?, ?, ?, ?, ?); + +-- name: GetAnnouncement :one +SELECT + r.id, + r.title, + r.content_md, + r.image_url, + r.resource_type, + r.resource_list_id, + r.created_at, + r.updated_at, + a.event_list_id, + a.approved_by_list_id, + a.visibility, + a.announce_at, + a.discord_channel_id, + a.discord_message_id +FROM resources r +INNER JOIN announcements a ON r.id = a.id +WHERE r.id = ?; diff --git a/internal/db/sqlite/queries.sql.go b/internal/db/sqlite/queries.sql.go new file mode 100644 index 0000000..40128cc --- /dev/null +++ b/internal/db/sqlite/queries.sql.go @@ -0,0 +1,326 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.23.0 +// source: queries.sql + +package sqlite + +import ( + "context" + "database/sql" +) + +const addResource = `-- name: AddResource :exec +INSERT INTO resource_references (resource_id, resource_list_id, index_in_list, created_at, updated_at) VALUES (?, ?, ?, ?, ?) +` + +type AddResourceParams struct { + ResourceID string `json:"resource_id"` + ResourceListID string `json:"resource_list_id"` + IndexInList int64 `json:"index_in_list"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (q *Queries) AddResource(ctx context.Context, arg AddResourceParams) error { + _, err := q.db.ExecContext(ctx, addResource, + arg.ResourceID, + arg.ResourceListID, + arg.IndexInList, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createAnnouncement = `-- name: CreateAnnouncement :exec +INSERT INTO announcements (id, event_list_id, approved_by_list_id, visibility, announce_at, discord_channel_id, discord_message_id) VALUES (?, ?, ?, ?, ?, ?, ?) +` + +type CreateAnnouncementParams struct { + ID string `json:"id"` + EventListID sql.NullString `json:"event_list_id"` + ApprovedByListID sql.NullString `json:"approved_by_list_id"` + Visibility string `json:"visibility"` + AnnounceAt int64 `json:"announce_at"` + DiscordChannelID sql.NullString `json:"discord_channel_id"` + DiscordMessageID sql.NullString `json:"discord_message_id"` +} + +func (q *Queries) CreateAnnouncement(ctx context.Context, arg CreateAnnouncementParams) error { + _, err := q.db.ExecContext(ctx, createAnnouncement, + arg.ID, + arg.EventListID, + arg.ApprovedByListID, + arg.Visibility, + arg.AnnounceAt, + arg.DiscordChannelID, + arg.DiscordMessageID, + ) + return err +} + +const createEvent = `-- name: CreateEvent :exec +INSERT INTO events (id, location, start_at, duration_ms, is_all_day, host, visibility) VALUES (?, ?, ?, ?, ?, ?, ?) +` + +type CreateEventParams struct { + ID string `json:"id"` + Location string `json:"location"` + StartAt interface{} `json:"start_at"` + DurationMs interface{} `json:"duration_ms"` + IsAllDay bool `json:"is_all_day"` + Host string `json:"host"` + Visibility string `json:"visibility"` +} + +func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) error { + _, err := q.db.ExecContext(ctx, createEvent, + arg.ID, + arg.Location, + arg.StartAt, + arg.DurationMs, + arg.IsAllDay, + arg.Host, + arg.Visibility, + ) + return err +} + +const createResource = `-- name: CreateResource :exec +INSERT INTO resources (id, title, content_md, image_url, resource_type, resource_list_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateResourceParams struct { + ID string `json:"id"` + Title string `json:"title"` + ContentMd string `json:"content_md"` + ImageUrl sql.NullString `json:"image_url"` + ResourceType string `json:"resource_type"` + ResourceListID sql.NullString `json:"resource_list_id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (q *Queries) CreateResource(ctx context.Context, arg CreateResourceParams) error { + _, err := q.db.ExecContext(ctx, createResource, + arg.ID, + arg.Title, + arg.ContentMd, + arg.ImageUrl, + arg.ResourceType, + arg.ResourceListID, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createResourceList = `-- name: CreateResourceList :exec +INSERT INTO resource_lists (title, created_at, updated_at) VALUES (?, ?, ?) +` + +type CreateResourceListParams struct { + Title string `json:"title"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (q *Queries) CreateResourceList(ctx context.Context, arg CreateResourceListParams) error { + _, err := q.db.ExecContext(ctx, createResourceList, arg.Title, arg.CreatedAt, arg.UpdatedAt) + return err +} + +const createResourceReference = `-- name: CreateResourceReference :exec +INSERT INTO resource_references (resource_id, resource_list_id, created_at, updated_at) VALUES (?, ?, ?, ?) +` + +type CreateResourceReferenceParams struct { + ResourceID string `json:"resource_id"` + ResourceListID string `json:"resource_list_id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (q *Queries) CreateResourceReference(ctx context.Context, arg CreateResourceReferenceParams) error { + _, err := q.db.ExecContext(ctx, createResourceReference, + arg.ResourceID, + arg.ResourceListID, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deleteResource = `-- name: DeleteResource :exec +DELETE FROM resources WHERE id = ? +` + +func (q *Queries) DeleteResource(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteResource, id) + return err +} + +const getAnnouncement = `-- name: GetAnnouncement :one +SELECT + r.id, + r.title, + r.content_md, + r.image_url, + r.resource_type, + r.resource_list_id, + r.created_at, + r.updated_at, + a.event_list_id, + a.approved_by_list_id, + a.visibility, + a.announce_at, + a.discord_channel_id, + a.discord_message_id +FROM resources r +INNER JOIN announcements a ON r.id = a.id +WHERE r.id = ? +` + +type GetAnnouncementRow struct { + ID string `json:"id"` + Title string `json:"title"` + ContentMd string `json:"content_md"` + ImageUrl sql.NullString `json:"image_url"` + ResourceType string `json:"resource_type"` + ResourceListID sql.NullString `json:"resource_list_id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + EventListID sql.NullString `json:"event_list_id"` + ApprovedByListID sql.NullString `json:"approved_by_list_id"` + Visibility string `json:"visibility"` + AnnounceAt int64 `json:"announce_at"` + DiscordChannelID sql.NullString `json:"discord_channel_id"` + DiscordMessageID sql.NullString `json:"discord_message_id"` +} + +func (q *Queries) GetAnnouncement(ctx context.Context, id string) (GetAnnouncementRow, error) { + row := q.db.QueryRowContext(ctx, getAnnouncement, id) + var i GetAnnouncementRow + err := row.Scan( + &i.ID, + &i.Title, + &i.ContentMd, + &i.ImageUrl, + &i.ResourceType, + &i.ResourceListID, + &i.CreatedAt, + &i.UpdatedAt, + &i.EventListID, + &i.ApprovedByListID, + &i.Visibility, + &i.AnnounceAt, + &i.DiscordChannelID, + &i.DiscordMessageID, + ) + return i, err +} + +const getEvent = `-- name: GetEvent :one +SELECT + r.id, + r.title, + r.content_md, + r.image_url, + r.resource_type, + r.resource_list_id, + r.created_at, + r.updated_at, + e.location, + e.start_at, + e.duration_ms, + e.is_all_day, + e.host, + e.visibility +FROM resources r +INNER JOIN events e ON r.id = e.id +WHERE r.id = ? +` + +type GetEventRow struct { + ID string `json:"id"` + Title string `json:"title"` + ContentMd string `json:"content_md"` + ImageUrl sql.NullString `json:"image_url"` + ResourceType string `json:"resource_type"` + ResourceListID sql.NullString `json:"resource_list_id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Location string `json:"location"` + StartAt interface{} `json:"start_at"` + DurationMs interface{} `json:"duration_ms"` + IsAllDay bool `json:"is_all_day"` + Host string `json:"host"` + Visibility string `json:"visibility"` +} + +func (q *Queries) GetEvent(ctx context.Context, id string) (GetEventRow, error) { + row := q.db.QueryRowContext(ctx, getEvent, id) + var i GetEventRow + err := row.Scan( + &i.ID, + &i.Title, + &i.ContentMd, + &i.ImageUrl, + &i.ResourceType, + &i.ResourceListID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Location, + &i.StartAt, + &i.DurationMs, + &i.IsAllDay, + &i.Host, + &i.Visibility, + ) + return i, err +} + +const getResourceList = `-- name: GetResourceList :many +SELECT rr.resource_id, rr.resource_list_id, rr.created_at, rr.updated_at +FROM resource_references rr +JOIN resources r ON rr.resource_id = r.id +JOIN resource_lists rl ON rr.resource_list_id = rl.id +WHERE rl.id = ? +ORDER BY rr.index_in_list ASC +` + +type GetResourceListRow struct { + ResourceID string `json:"resource_id"` + ResourceListID string `json:"resource_list_id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (q *Queries) GetResourceList(ctx context.Context, id string) ([]GetResourceListRow, error) { + rows, err := q.db.QueryContext(ctx, getResourceList, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetResourceListRow + for rows.Next() { + var i GetResourceListRow + if err := rows.Scan( + &i.ResourceID, + &i.ResourceListID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/sqlite/schema.go b/internal/db/sqlite/schema.go new file mode 100644 index 0000000..3a000dc --- /dev/null +++ b/internal/db/sqlite/schema.go @@ -0,0 +1,55 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.23.0 + +package sqlite + +import ( + "database/sql" +) + +type Announcement struct { + ID string `json:"id"` + EventListID sql.NullString `json:"event_list_id"` + ApprovedByListID sql.NullString `json:"approved_by_list_id"` + Visibility string `json:"visibility"` + AnnounceAt int64 `json:"announce_at"` + DiscordChannelID sql.NullString `json:"discord_channel_id"` + DiscordMessageID sql.NullString `json:"discord_message_id"` +} + +type Event struct { + ID string `json:"id"` + Location string `json:"location"` + StartAt interface{} `json:"start_at"` + DurationMs interface{} `json:"duration_ms"` + IsAllDay bool `json:"is_all_day"` + Host string `json:"host"` + Visibility string `json:"visibility"` +} + +type Resource struct { + ID string `json:"id"` + Title string `json:"title"` + ContentMd string `json:"content_md"` + ImageUrl sql.NullString `json:"image_url"` + ResourceType string `json:"resource_type"` + ResourceListID sql.NullString `json:"resource_list_id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type ResourceList struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type ResourceReference struct { + ResourceID string `json:"resource_id"` + ResourceListID string `json:"resource_list_id"` + IndexInList int64 `json:"index_in_list"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} diff --git a/internal/db/sqlite/schema.sql b/internal/db/sqlite/schema.sql new file mode 100644 index 0000000..eb4f5b4 --- /dev/null +++ b/internal/db/sqlite/schema.sql @@ -0,0 +1,58 @@ +-- Language: sqlite + +-- Create the 'resource_lists' table. +CREATE TABLE IF NOT EXISTS resource_lists ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (id) +); + +-- Create the 'resources' table. +CREATE TABLE IF NOT EXISTS resources ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + content_md TEXT NOT NULL, + image_url TEXT, + resource_type TEXT NOT NULL, + resource_list_id TEXT REFERENCES resource_lists(id), -- Related resources are added to this list. + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (id, resource_list_id) +); + +-- Create the 'resource_references' table. +CREATE TABLE IF NOT EXISTS resource_references ( + resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + resource_list_id TEXT NOT NULL REFERENCES resource_lists(id) ON DELETE CASCADE, + index_in_list INTEGER NOT NULL, -- The index of the resource in the list. + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (resource_id, resource_list_id), + PRIMARY KEY (resource_id, resource_list_id) +); + +-- Create the 'events' table which is a table of event resources. +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE, + location TEXT NOT NULL, + start_at NUMBER NOT NULL, -- Start time in UTC milliseconds. + duration_ms NUMBER NOT NULL, + is_all_day BOOLEAN NOT NULL, + host TEXT NOT NULL, -- Accepts team ID or plain text. + visibility TEXT NOT NULL, -- Accepts 'public' or 'private'. + UNIQUE (id) +) + +-- Create the 'announcements' table which is a table of announcement resources. +CREATE TABLE IF NOT EXISTS announcements ( + id TEXT PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE, + event_list_id TEXT REFERENCES resource_lists(id) ON DELETE CASCADE, + approved_by_list_id TEXT REFERENCES resource_lists(id) ON DELETE CASCADE, + visibility TEXT NOT NULL, -- Accepts 'public' or 'private'. + announce_at INTEGER NOT NULL, -- UTC milliseconds. + discord_channel_id TEXT, -- Discord channel ID. If present, the announcement has been posted. + discord_message_id TEXT, -- Discord message ID. If present, the announcement has been posted. + UNIQUE (id) +) \ No newline at end of file diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..fddcb15 --- /dev/null +++ b/shell.nix @@ -0,0 +1,48 @@ +# Reference: +# https://github.com/diamondburned/acmregister/blob/2ad96e84ed069c554698261530cbd875cafe4a26/shell.nix + +{ sysPkgs ? import {} }: + +let + lib = sysPkgs.lib; + overlay = self: super: { + go = super.go_1_21; + }; + + pkgs = import (sysPkgs.fetchFromGitHub { + owner = "NixOS"; + repo = "nixpkgs"; + # most recent commit as of 11/13/23 + rev = "bb142a6838c823a2cd8235e1d0dd3641e7dcfd63"; + hash = "sha256:0nbicig1zps3sbk7krhznay73nxr049hgpwyl57qnrbb0byzq9iy"; + }) { + overlays = [ overlay ]; + }; + + nilaway = pkgs.buildGoModule rec { + name = "nilaway"; + # version = "0.0.0-20231031155528-970a3b8379e3"; + + src = pkgs.fetchFromGitHub { + owner = "uber-go"; + repo = "nilaway"; + # rev = "v${version}"; + rev = "970a3b8379e324d48c70d34f03f3f309432b4d41"; + sha256 = "1chahgba7fqr3jz7dcqb3paa8v0xgy2qsvjwya9gqqwdzli6rdsg"; + }; + # vendorSha256 = lib.fakeSha256; + vendorSha256 = "sha256-E44rokDNcyc8XSdvAQ/DEyltwO6zaErckAH5+JEXxrM="; + proxyVendor = true; + doCheck = false; + }; +in pkgs.mkShell { + buildInputs = with pkgs; [ + go + gotools + gopls + sqlc + nilaway + # TODO: Add SQL formatting tool. + ]; +} +