From 285fd3eaf09a5608d52f92bb528787f2d91ff3af Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Wed, 28 Apr 2021 23:52:27 -0500 Subject: [PATCH] Start work on forum category indexes --- ...021-04-29T043548Z_RenameModeratedFields.go | 60 +++++++ src/models/post.go | 2 +- src/models/thread.go | 2 +- src/templates/types.go | 5 +- src/website/feed.go | 6 +- src/website/forums.go | 164 ++++++++++++++++++ src/website/landing.go | 4 +- src/website/routes.go | 2 +- 8 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 src/migration/migrations/2021-04-29T043548Z_RenameModeratedFields.go create mode 100644 src/website/forums.go diff --git a/src/migration/migrations/2021-04-29T043548Z_RenameModeratedFields.go b/src/migration/migrations/2021-04-29T043548Z_RenameModeratedFields.go new file mode 100644 index 00000000..5ac39f86 --- /dev/null +++ b/src/migration/migrations/2021-04-29T043548Z_RenameModeratedFields.go @@ -0,0 +1,60 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(RenameModeratedFields{}) +} + +type RenameModeratedFields struct{} + +func (m RenameModeratedFields) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 4, 29, 4, 35, 48, 0, time.UTC)) +} + +func (m RenameModeratedFields) Name() string { + return "RenameModeratedFields" +} + +func (m RenameModeratedFields) Description() string { + return "Rename 'moderated' to 'deleted'" +} + +func (m RenameModeratedFields) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + ALTER TABLE handmade_thread + RENAME moderated TO deleted; + ALTER TABLE handmade_post + RENAME moderated TO deleted; + `) + if err != nil { + return oops.New(err, "failed to rename columns") + } + + _, err = tx.Exec(ctx, + ` + ALTER TABLE handmade_thread + ALTER deleted TYPE bool USING CASE WHEN deleted = 0 THEN FALSE ELSE TRUE END; + ALTER TABLE handmade_thread ALTER COLUMN deleted SET DEFAULT FALSE; + + ALTER TABLE handmade_post ALTER COLUMN deleted SET DEFAULT FALSE; + `, + ) + if err != nil { + return oops.New(err, "failed to convert ints to bools") + } + + return nil +} + +func (m RenameModeratedFields) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/models/post.go b/src/models/post.go index 2020a0eb..6236b176 100644 --- a/src/models/post.go +++ b/src/models/post.go @@ -24,7 +24,7 @@ type Post struct { PostDate time.Time `db:"postdate"` IP net.IPNet `db:"ip"` Sticky bool `db:"sticky"` - Moderated bool `db:"moderated"` // TODO: I'm not sure this is ever meaningfully used. It always seems to be 0 / false? + Deleted bool `db:"deleted"` // TODO: I'm not sure this is ever meaningfully used. It always seems to be 0 / false? Hits int `db:"hits"` Featured bool `db:"featured"` FeatureVotes int `db:"featurevotes"` // TODO: Remove this column from the db, it's never used diff --git a/src/models/thread.go b/src/models/thread.go index 0929e303..525fb47e 100644 --- a/src/models/thread.go +++ b/src/models/thread.go @@ -10,7 +10,7 @@ type Thread struct { ReplyCount int `db:"reply_count"` Sticky bool `db:"sticky"` Locked bool `db:"locked"` - Moderated int `db:"moderated"` + Deleted bool `db:"deleted"` FirstID *int `db:"first_id"` LastID *int `db:"last_id"` diff --git a/src/templates/types.go b/src/templates/types.go index 149913d6..5f996212 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -17,9 +17,8 @@ type BaseData struct { type Thread struct { Title string - Locked bool - Sticky bool - Moderated bool + Locked bool + Sticky bool } type Post struct { diff --git a/src/website/feed.go b/src/website/feed.go index 39f98a1f..92d31b04 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -32,7 +32,7 @@ func Feed(c *RequestContext) ResponseData { handmade_post AS post WHERE post.category_kind = ANY ($1) - AND NOT moderated + AND NOT deleted `, []models.CategoryKind{models.CatKindForum, models.CatKindBlog, models.CatKindWiki, models.CatKindLibraryResource}, ) @@ -41,7 +41,7 @@ func Feed(c *RequestContext) ResponseData { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get count of feed posts")) } - numPages := int(math.Ceil(float64(numPosts) / 30)) + numPages := int(math.Ceil(float64(numPosts) / postsPerPage)) page := 1 pageString, hasPage := c.PathParams["page"] @@ -102,7 +102,7 @@ func Feed(c *RequestContext) ResponseData { LEFT OUTER JOIN auth_user ON post.author_id = auth_user.id WHERE post.category_kind = ANY ($2) - AND post.moderated = FALSE + AND post.deleted = FALSE AND post.thread_id IS NOT NULL ORDER BY postdate DESC LIMIT $3 OFFSET $4 diff --git a/src/website/forums.go b/src/website/forums.go new file mode 100644 index 00000000..27d5ad08 --- /dev/null +++ b/src/website/forums.go @@ -0,0 +1,164 @@ +package website + +import ( + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" +) + +func ForumCategory(c *RequestContext) ResponseData { + const threadsPerPage = 25 + + catPath := c.PathParams["cats"] + catSlugs := strings.Split(catPath, "/") + + catSlug := catSlugs[len(catSlugs)-1] + if len(catSlugs) == 1 { + catSlug = "" + } + + // TODO: Is this query right? Do we need to do a better special case for when it's the root category? + currentCatId, err := db.QueryInt(c.Context(), c.Conn, + ` + SELECT id + FROM handmade_category + WHERE + slug = $1 + AND kind = $2 + AND project_id = $3 + `, + catSlug, + models.CatKindForum, + c.CurrentProject.ID, + ) + if err != nil { + panic(oops.New(err, "failed to get current category id")) + } + + numThreads, err := db.QueryInt(c.Context(), c.Conn, + ` + SELECT COUNT(*) + FROM handmade_thread AS thread + WHERE + thread.category_id = $1 + AND NOT thread.deleted + `, + currentCatId, + ) + if err != nil { + panic(oops.New(err, "failed to get count of threads")) + } + + numPages := int(math.Ceil(float64(numThreads) / threadsPerPage)) + + page := 1 + pageString, hasPage := c.PathParams["page"] + if hasPage && pageString != "" { + if pageParsed, err := strconv.Atoi(pageString); err == nil { + page = pageParsed + } else { + return c.Redirect("/feed", http.StatusSeeOther) // TODO + } + } + if page < 1 || numPages < page { + return c.Redirect("/feed", http.StatusSeeOther) // TODO + } + + howManyThreadsToSkip := (page - 1) * threadsPerPage + + var currentUserId *int + if c.CurrentUser != nil { + currentUserId = &c.CurrentUser.ID + } + + type mainPostsQueryResult struct { + Thread models.Thread `db:"thread"` + FirstPost models.Post `db:"firstpost"` + LastPost models.Post `db:"lastpost"` + ThreadLastReadTime *time.Time `db:"tlri.lastread"` + CatLastReadTime *time.Time `db:"clri.lastread"` + } + itMainThreads, err := db.Query(c.Context(), c.Conn, mainPostsQueryResult{}, + ` + SELECT $columns + FROM + handmade_thread AS thread + JOIN handmade_post AS firstpost ON thread.first_id = firstpost.id + JOIN handmade_post AS lastpost ON thread.last_id = lastpost.id + LEFT OUTER JOIN handmade_threadlastreadinfo AS tlri ON ( + tlri.thread_id = thread.id + AND tlri.user_id = $2 + ) + LEFT OUTER JOIN handmade_categorylastreadinfo AS clri ON ( + clri.category_id = $1 + AND clri.user_id = $2 + ) + -- LEFT OUTER JOIN auth_user ON post.author_id = auth_user.id + WHERE + thread.category_id = $1 + AND NOT thread.deleted + ORDER BY lastpost.postdate DESC + LIMIT $3 OFFSET $4 + `, + currentCatId, + currentUserId, + threadsPerPage, + howManyThreadsToSkip, + ) + if err != nil { + panic(oops.New(err, "failed to fetch threads")) + } + + var res ResponseData + + for _, irow := range itMainThreads.ToSlice() { + row := irow.(*mainPostsQueryResult) + res.Write([]byte(fmt.Sprintf("%s\n", row.Thread.Title))) + } + + // --------------------- + // Subcategory things + // --------------------- + + c.Perf.StartBlock("SQL", "Fetch subcategories") + type queryResult struct { + Cat models.Category `db:"cat"` + } + itSubcats, err := db.Query(c.Context(), c.Conn, queryResult{}, + ` + WITH current AS ( + SELECT id + FROM handmade_category + WHERE + slug = $1 + AND kind = $2 + AND project_id = $3 + ) + SELECT $columns + FROM + handmade_category AS cat, + current + WHERE + cat.id = current.id + OR cat.parent_id = current.id + `, + catSlug, + models.CatKindForum, + c.CurrentProject.ID, + ) + if err != nil { + panic(oops.New(err, "failed to fetch subcategories")) + } + c.Perf.EndBlock() + + _ = itSubcats // TODO: Actually query subcategory post data + + return res +} diff --git a/src/website/landing.go b/src/website/landing.go index 879e08ee..3cc59235 100644 --- a/src/website/landing.go +++ b/src/website/landing.go @@ -101,7 +101,7 @@ func Index(c *RequestContext) ResponseData { WHERE post.project_id = $2 AND post.category_kind IN ($3, $4, $5, $6) - AND post.moderated = FALSE + AND post.deleted = FALSE ORDER BY postdate DESC LIMIT $7 `, @@ -258,7 +258,7 @@ func Index(c *RequestContext) ResponseData { cat.project_id = $1 AND cat.kind = $2 AND post.id = thread.first_id - AND thread.moderated = 0 + AND NOT thread.deleted ORDER BY post.postdate DESC LIMIT 1 `, diff --git a/src/website/routes.go b/src/website/routes.go index 7079bab3..49f26cf5 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -72,7 +72,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt }) mainRoutes.GET(`^/feed(/(?P.+)?)?$`, Feed) - // mainRoutes.GET(`^/(?Pforums(/cat)*)$`, Category) + mainRoutes.GET(`^/(?Pforums(/.+?)*)$`, ForumCategory) // mainRoutes.GET(`^/(?Pforums(/cat)*)/t/(?P\d+)/p/(?P\d+)$`, ForumPost) mainRoutes.GET("^/assets/project.css$", ProjectCSS)