create forum package

This commit is contained in:
Sky Johnson 2025-08-08 23:52:23 -05:00
parent 42e090b05f
commit 96857e8110
4 changed files with 1832 additions and 0 deletions

131
internal/forum/builder.go Normal file
View File

@ -0,0 +1,131 @@
package forum
import (
"fmt"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Builder provides a fluent interface for creating forum posts
type Builder struct {
forum *Forum
db *database.DB
}
// NewBuilder creates a new forum post builder
func NewBuilder(db *database.DB) *Builder {
now := time.Now().Unix()
return &Builder{
forum: &Forum{
db: db,
Posted: now,
LastPost: now, // Default to same as posted time
Parent: 0, // Default to thread (no parent)
Replies: 0, // Default to no replies
},
db: db,
}
}
// WithAuthor sets the author user ID
func (b *Builder) WithAuthor(authorID int) *Builder {
b.forum.Author = authorID
return b
}
// WithTitle sets the forum post title
func (b *Builder) WithTitle(title string) *Builder {
b.forum.Title = title
return b
}
// WithContent sets the forum post content
func (b *Builder) WithContent(content string) *Builder {
b.forum.Content = content
return b
}
// WithParent sets the parent post ID (for replies)
func (b *Builder) WithParent(parentID int) *Builder {
b.forum.Parent = parentID
return b
}
// AsReply is a convenience method to set this as a reply to another post
func (b *Builder) AsReply(parentID int) *Builder {
return b.WithParent(parentID)
}
// AsThread ensures this is a top-level thread (parent = 0)
func (b *Builder) AsThread() *Builder {
b.forum.Parent = 0
return b
}
// WithPosted sets the posted timestamp
func (b *Builder) WithPosted(posted int64) *Builder {
b.forum.Posted = posted
return b
}
// WithPostedTime sets the posted timestamp from a time.Time
func (b *Builder) WithPostedTime(t time.Time) *Builder {
b.forum.Posted = t.Unix()
return b
}
// WithLastPost sets the last post timestamp
func (b *Builder) WithLastPost(lastPost int64) *Builder {
b.forum.LastPost = lastPost
return b
}
// WithLastPostTime sets the last post timestamp from a time.Time
func (b *Builder) WithLastPostTime(t time.Time) *Builder {
b.forum.LastPost = t.Unix()
return b
}
// WithReplies sets the initial reply count
func (b *Builder) WithReplies(replies int) *Builder {
b.forum.Replies = replies
return b
}
// Create saves the forum post to the database and returns the created post with ID
func (b *Builder) Create() (*Forum, error) {
// Use a transaction to ensure we can get the ID
var forum *Forum
err := b.db.Transaction(func(tx *database.Tx) error {
query := `INSERT INTO forum (posted, last_post, author, parent, replies, title, content)
VALUES (?, ?, ?, ?, ?, ?, ?)`
if err := tx.Exec(query, b.forum.Posted, b.forum.LastPost, b.forum.Author,
b.forum.Parent, b.forum.Replies, b.forum.Title, b.forum.Content); err != nil {
return fmt.Errorf("failed to insert forum post: %w", err)
}
// Get the last insert ID
var id int
err := tx.Query("SELECT last_insert_rowid()", func(stmt *sqlite.Stmt) error {
id = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get insert ID: %w", err)
}
b.forum.ID = id
forum = b.forum
return nil
})
if err != nil {
return nil, err
}
return forum, nil
}

627
internal/forum/doc.go Normal file
View File

@ -0,0 +1,627 @@
/*
Package forum is the active record implementation for forum posts and threads in the game.
The forum package provides a complete forum system with thread and reply functionality, search capabilities, and comprehensive post management. It supports hierarchical discussions with parent/child relationships between posts.
# Basic Usage
To retrieve a forum post by ID:
post, err := forum.Find(db, 1)
if err != nil {
log.Fatal(err)
}
fmt.Printf("[%s] %s by user %d\n",
post.PostedTime().Format("Jan 2"), post.Title, post.Author)
To get all forum threads (top-level posts):
threads, err := forum.Threads(db)
if err != nil {
log.Fatal(err)
}
for _, thread := range threads {
fmt.Printf("Thread: %s (%d replies)\n", thread.Title, thread.Replies)
}
To get replies to a specific thread:
replies, err := forum.ByParent(db, threadID)
if err != nil {
log.Fatal(err)
}
To search forum posts:
results, err := forum.Search(db, "strategy")
if err != nil {
log.Fatal(err)
}
# Creating Posts with Builder Pattern
The package provides a fluent builder interface for creating new forum posts:
## Creating a New Thread
thread, err := forum.NewBuilder(db).
WithAuthor(userID).
WithTitle("New Strategy Discussion").
WithContent("What are your thoughts on the best character builds?").
AsThread().
Create()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Created thread with ID: %d\n", thread.ID)
## Creating a Reply
reply, err := forum.NewBuilder(db).
WithAuthor(userID).
WithTitle("Re: Strategy Discussion").
WithContent("I think mage builds are overpowered right now.").
AsReply(parentThreadID).
Create()
if err != nil {
log.Fatal(err)
}
The builder automatically sets timestamps to the current time if not specified.
# Updating Posts
Forum posts can be modified and saved back to the database:
post, _ := forum.Find(db, 1)
post.Title = "[UPDATED] " + post.Title
post.Content = post.Content + "\n\nEDIT: Added clarification."
post.UpdateLastPost() // Update last activity timestamp
err := post.Save()
if err != nil {
log.Fatal(err)
}
# Deleting Posts
Posts can be removed from the database:
post, _ := forum.Find(db, 1)
err := post.Delete()
if err != nil {
log.Fatal(err)
}
# Database Schema
The forum table has the following structure:
CREATE TABLE forum (
id INTEGER PRIMARY KEY AUTOINCREMENT,
posted INTEGER NOT NULL DEFAULT (unixepoch()),
last_post INTEGER NOT NULL DEFAULT (unixepoch()),
author INTEGER NOT NULL,
parent INTEGER NOT NULL DEFAULT 0,
replies INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
content TEXT NOT NULL
)
Where:
- id: Unique identifier for the forum post
- posted: Unix timestamp when the post was originally created
- last_post: Unix timestamp of the most recent activity on this thread/post
- author: User ID of the post author
- parent: Parent post ID (0 for top-level threads, >0 for replies)
- replies: Number of direct replies to this post
- title: Post title/subject line
- content: Post content/body text
# Thread and Reply System
## Thread Structure
The forum uses a parent/child hierarchy:
// Top-level threads have parent = 0
if post.IsThread() {
fmt.Println("This is a main thread")
}
// Replies have parent > 0
if post.IsReply() {
fmt.Printf("This is a reply to post %d\n", post.Parent)
}
## Working with Threads
Get all top-level threads:
threads, _ := forum.Threads(db)
fmt.Println("=== Forum Threads ===")
for _, thread := range threads {
age := thread.ActivityAge()
var activityStr string
if age < time.Hour {
activityStr = fmt.Sprintf("%d minutes ago", int(age.Minutes()))
} else if age < 24*time.Hour {
activityStr = fmt.Sprintf("%d hours ago", int(age.Hours()))
} else {
activityStr = fmt.Sprintf("%d days ago", int(age.Hours()/24))
}
fmt.Printf("📌 %s (%d replies, last activity %s)\n",
thread.Title, thread.Replies, activityStr)
}
## Working with Replies
Get all replies to a thread:
thread, _ := forum.Find(db, threadID)
replies, _ := thread.GetReplies()
fmt.Printf("=== %s ===\n", thread.Title)
fmt.Printf("Posted by user %d on %s\n\n",
thread.Author, thread.PostedTime().Format("Jan 2, 2006"))
fmt.Println(thread.Content)
if len(replies) > 0 {
fmt.Printf("\n--- %d Replies ---\n", len(replies))
for i, reply := range replies {
fmt.Printf("[%d] by user %d on %s:\n%s\n\n",
i+1, reply.Author, reply.PostedTime().Format("Jan 2 15:04"),
reply.Content)
}
}
## Navigation Between Posts
Navigate the thread hierarchy:
reply, _ := forum.Find(db, replyID)
// Get the parent thread
thread, err := reply.GetThread()
if err == nil {
fmt.Printf("This reply belongs to thread: %s\n", thread.Title)
}
// Get all sibling replies
siblings, _ := forum.ByParent(db, reply.Parent)
fmt.Printf("This thread has %d total replies\n", len(siblings))
# Search and Filtering
## Text Search
Search within titles and content:
// Search for posts about "pvp"
pvpPosts, err := forum.Search(db, "pvp")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d posts about PvP:\n", len(pvpPosts))
for _, post := range pvpPosts {
fmt.Printf("- %s: %s\n", post.Title, post.Preview(60))
}
Search is case-insensitive and searches both titles and content.
## Author-Based Queries
Find posts by specific users:
// Get all posts by a user
userPosts, err := forum.ByAuthor(db, userID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("User %d has made %d posts:\n", userID, len(userPosts))
for _, post := range userPosts {
postType := "thread"
if post.IsReply() {
postType = "reply"
}
fmt.Printf("- [%s] %s (%s ago)\n",
postType, post.Title, post.PostAge())
}
## Activity-Based Queries
Find recent activity:
// Get recent forum activity
recentPosts, err := forum.Recent(db, 20)
if err != nil {
log.Fatal(err)
}
// Get activity since user's last visit
lastVisit := getUserLastVisit(userID)
newActivity, err := forum.Since(db, lastVisit)
if len(newActivity) > 0 {
fmt.Printf("There have been %d new posts since your last visit\n",
len(newActivity))
}
# Post Management
## Reply Count Management
Manage reply counts when posts are created or deleted:
// When creating a reply
reply, err := forum.NewBuilder(db).
WithAuthor(userID).
WithContent("Great point!").
AsReply(parentID).
Create()
if err == nil {
// Update parent thread's reply count and last activity
parentThread, _ := forum.Find(db, parentID)
parentThread.IncrementReplies()
parentThread.UpdateLastPost()
parentThread.Save()
}
// When deleting a reply
replyToDelete, _ := forum.Find(db, replyID)
parentID := replyToDelete.Parent
replyToDelete.Delete()
// Update parent thread
if parentID > 0 {
parentThread, _ := forum.Find(db, parentID)
parentThread.DecrementReplies()
parentThread.Save()
}
## Content Analysis
Analyze post content:
post, _ := forum.Find(db, postID)
// Basic content metrics
fmt.Printf("Post length: %d characters\n", post.Length())
fmt.Printf("Word count: %d words\n", post.WordCount())
// Check for specific terms
if post.Contains("bug") {
fmt.Println("This post mentions a bug")
}
// Generate preview for listings
preview := post.Preview(100)
fmt.Printf("Preview: %s\n", preview)
## Time Analysis
Track posting and activity patterns:
post, _ := forum.Find(db, postID)
postAge := post.PostAge()
activityAge := post.ActivityAge()
fmt.Printf("Post created %v ago\n", postAge)
fmt.Printf("Last activity %v ago\n", activityAge)
if post.IsRecentActivity() {
fmt.Println("This thread has recent activity")
}
# Forum Display Patterns
## Thread Listing
Display forum index:
func displayForumIndex(db *database.DB) {
threads, _ := forum.Threads(db)
fmt.Println("=== Game Forum ===")
fmt.Printf("%-40s %-8s %-15s\n", "Thread", "Replies", "Last Activity")
fmt.Println(strings.Repeat("-", 65))
for _, thread := range threads {
title := thread.Title
if len(title) > 37 {
title = title[:37] + "..."
}
age := thread.ActivityAge()
var ageStr string
if age < time.Hour {
ageStr = fmt.Sprintf("%dm ago", int(age.Minutes()))
} else if age < 24*time.Hour {
ageStr = fmt.Sprintf("%dh ago", int(age.Hours()))
} else {
ageStr = fmt.Sprintf("%dd ago", int(age.Hours()/24))
}
fmt.Printf("%-40s %-8d %-15s\n", title, thread.Replies, ageStr)
}
}
## Thread View
Display a complete thread with replies:
func displayThread(db *database.DB, threadID int) error {
thread, err := forum.Find(db, threadID)
if err != nil {
return err
}
if !thread.IsThread() {
return fmt.Errorf("post %d is not a thread", threadID)
}
// Display thread
fmt.Printf("=== %s ===\n", thread.Title)
fmt.Printf("By user %d on %s\n\n",
thread.Author, thread.PostedTime().Format("January 2, 2006 at 3:04 PM"))
fmt.Println(thread.Content)
// Display replies
replies, _ := thread.GetReplies()
if len(replies) > 0 {
fmt.Printf("\n--- %d Replies ---\n\n", len(replies))
for i, reply := range replies {
fmt.Printf("#%d by user %d on %s:\n",
i+1, reply.Author, reply.PostedTime().Format("Jan 2 at 3:04 PM"))
fmt.Println(reply.Content)
fmt.Println()
}
}
return nil
}
# Moderation Features
## Content Moderation
Tools for forum moderation:
// Flag posts for review
func moderatePost(db *database.DB, postID int) {
post, err := forum.Find(db, postID)
if err != nil {
return
}
// Check for very short posts (potential spam)
if post.WordCount() < 3 {
fmt.Printf("Short post flagged: %s\n", post.Preview(30))
}
// Check for very long posts
if post.Length() > 5000 {
fmt.Printf("Very long post from user %d\n", post.Author)
}
// Check for specific terms
suspiciousTerms := []string{"spam", "hack", "cheat"}
for _, term := range suspiciousTerms {
if post.Contains(term) {
fmt.Printf("Post contains suspicious term '%s'\n", term)
}
}
}
## User Activity Analysis
Analyze user forum behavior:
func analyzeUserActivity(db *database.DB, userID int) {
posts, _ := forum.ByAuthor(db, userID)
fmt.Printf("User %d forum activity:\n", userID)
fmt.Printf("- Total posts: %d\n", len(posts))
threadCount := 0
replyCount := 0
totalWords := 0
for _, post := range posts {
if post.IsThread() {
threadCount++
} else {
replyCount++
}
totalWords += post.WordCount()
}
fmt.Printf("- Threads started: %d\n", threadCount)
fmt.Printf("- Replies posted: %d\n", replyCount)
if len(posts) > 0 {
avgWords := totalWords / len(posts)
fmt.Printf("- Average words per post: %d\n", avgWords)
latest := posts[0] // ByAuthor returns newest first
fmt.Printf("- Last post: %s (%s ago)\n",
latest.Title, latest.PostAge())
}
}
# Performance Considerations
## Efficient Queries
Optimize database queries for forum performance:
// Use specific queries for common operations
threads, _ := forum.Threads(db) // More efficient than filtering All()
// Limit results for pagination
recentPosts, _ := forum.Recent(db, 25) // Get page worth of data
// Use specific parent queries
replies, _ := forum.ByParent(db, threadID) // Efficient for thread display
## Caching Strategies
Cache frequently accessed data:
// Cache popular threads
var popularThreadsCache []*forum.Forum
var cacheTime time.Time
func getPopularThreads(db *database.DB) []*forum.Forum {
// Refresh cache every 5 minutes
if time.Since(cacheTime) > 5*time.Minute {
threads, _ := forum.Threads(db)
// Sort by activity (replies + recent posts)
sort.Slice(threads, func(i, j int) bool {
scoreI := threads[i].Replies
scoreJ := threads[j].Replies
// Bonus for recent activity
if threads[i].IsRecentActivity() {
scoreI += 10
}
if threads[j].IsRecentActivity() {
scoreJ += 10
}
return scoreI > scoreJ
})
// Cache top 10
if len(threads) > 10 {
threads = threads[:10]
}
popularThreadsCache = threads
cacheTime = time.Now()
}
return popularThreadsCache
}
# Integration Examples
## User Notifications
Integrate with notification system:
func notifyNewReply(db *database.DB, replyID int) {
reply, err := forum.Find(db, replyID)
if err != nil {
return
}
// Get the parent thread
thread, err := reply.GetThread()
if err != nil {
return
}
// Notify thread author if different from reply author
if thread.Author != reply.Author {
message := fmt.Sprintf("New reply to your thread '%s'", thread.Title)
sendNotification(thread.Author, message)
}
// Notify other participants in the thread
allReplies, _ := thread.GetReplies()
participants := make(map[int]bool)
participants[thread.Author] = true
for _, r := range allReplies {
if r.Author != reply.Author && !participants[r.Author] {
message := fmt.Sprintf("New activity in thread '%s'", thread.Title)
sendNotification(r.Author, message)
participants[r.Author] = true
}
}
}
## Search Integration
Provide advanced search capabilities:
func advancedSearch(db *database.DB, query string, authorID int,
onlyThreads bool, since time.Time) []*forum.Forum {
var results []*forum.Forum
// Start with text search
if query != "" {
textResults, _ := forum.Search(db, query)
results = append(results, textResults...)
} else {
allPosts, _ := forum.All(db)
results = allPosts
}
// Apply filters
var filtered []*forum.Forum
sinceUnix := since.Unix()
for _, post := range results {
// Author filter
if authorID > 0 && post.Author != authorID {
continue
}
// Thread-only filter
if onlyThreads && !post.IsThread() {
continue
}
// Time filter
if post.LastPost < sinceUnix {
continue
}
filtered = append(filtered, post)
}
return filtered
}
# Error Handling
Common error scenarios and handling:
post, err := forum.Find(db, postID)
if err != nil {
// Handle post not found or database issues
log.Printf("Failed to find post %d: %v", postID, err)
return
}
// Validate post relationships
if post.IsReply() {
parentThread, err := post.GetThread()
if err != nil {
log.Printf("Warning: Reply %d has invalid parent %d",
post.ID, post.Parent)
}
}
// Save with error handling
if err := post.Save(); err != nil {
log.Printf("Failed to save post %d: %v", post.ID, err)
}
The forum package provides a complete forum system with hierarchical discussions, search capabilities, and comprehensive post management suitable for game communities.
*/
package forum

409
internal/forum/forum.go Normal file
View File

@ -0,0 +1,409 @@
package forum
import (
"fmt"
"strings"
"time"
"dk/internal/database"
"zombiezen.com/go/sqlite"
)
// Forum represents a forum post or thread in the database
type Forum struct {
ID int `json:"id"`
Posted int64 `json:"posted"`
LastPost int64 `json:"last_post"`
Author int `json:"author"`
Parent int `json:"parent"`
Replies int `json:"replies"`
Title string `json:"title"`
Content string `json:"content"`
db *database.DB
}
// Find retrieves a forum post by ID
func Find(db *database.DB, id int) (*Forum, error) {
forum := &Forum{db: db}
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE id = ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum.ID = stmt.ColumnInt(0)
forum.Posted = stmt.ColumnInt64(1)
forum.LastPost = stmt.ColumnInt64(2)
forum.Author = stmt.ColumnInt(3)
forum.Parent = stmt.ColumnInt(4)
forum.Replies = stmt.ColumnInt(5)
forum.Title = stmt.ColumnText(6)
forum.Content = stmt.ColumnText(7)
return nil
}, id)
if err != nil {
return nil, fmt.Errorf("failed to find forum post: %w", err)
}
if forum.ID == 0 {
return nil, fmt.Errorf("forum post with ID %d not found", id)
}
return forum, nil
}
// All retrieves all forum posts ordered by last post time (most recent first)
func All(db *database.DB) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum ORDER BY last_post DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
db: db,
}
forums = append(forums, forum)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve all forum posts: %w", err)
}
return forums, nil
}
// Threads retrieves all top-level forum threads (parent = 0)
func Threads(db *database.DB) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE parent = 0 ORDER BY last_post DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
db: db,
}
forums = append(forums, forum)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to retrieve forum threads: %w", err)
}
return forums, nil
}
// ByParent retrieves all replies to a specific thread/post
func ByParent(db *database.DB, parentID int) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE parent = ? ORDER BY posted ASC, id ASC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
db: db,
}
forums = append(forums, forum)
return nil
}, parentID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve forum replies: %w", err)
}
return forums, nil
}
// ByAuthor retrieves forum posts by a specific author
func ByAuthor(db *database.DB, authorID int) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE author = ? ORDER BY posted DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
db: db,
}
forums = append(forums, forum)
return nil
}, authorID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve forum posts by author: %w", err)
}
return forums, nil
}
// Recent retrieves the most recent forum activity (limited by count)
func Recent(db *database.DB, limit int) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum ORDER BY last_post DESC, id DESC LIMIT ?"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
db: db,
}
forums = append(forums, forum)
return nil
}, limit)
if err != nil {
return nil, fmt.Errorf("failed to retrieve recent forum posts: %w", err)
}
return forums, nil
}
// Search retrieves forum posts containing the search term in title or content
func Search(db *database.DB, term string) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?) ORDER BY last_post DESC, id DESC"
searchTerm := "%" + term + "%"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
db: db,
}
forums = append(forums, forum)
return nil
}, searchTerm, searchTerm)
if err != nil {
return nil, fmt.Errorf("failed to search forum posts: %w", err)
}
return forums, nil
}
// Since retrieves forum posts with activity since a specific timestamp
func Since(db *database.DB, since int64) ([]*Forum, error) {
var forums []*Forum
query := "SELECT id, posted, last_post, author, parent, replies, title, content FROM forum WHERE last_post >= ? ORDER BY last_post DESC, id DESC"
err := db.Query(query, func(stmt *sqlite.Stmt) error {
forum := &Forum{
ID: stmt.ColumnInt(0),
Posted: stmt.ColumnInt64(1),
LastPost: stmt.ColumnInt64(2),
Author: stmt.ColumnInt(3),
Parent: stmt.ColumnInt(4),
Replies: stmt.ColumnInt(5),
Title: stmt.ColumnText(6),
Content: stmt.ColumnText(7),
db: db,
}
forums = append(forums, forum)
return nil
}, since)
if err != nil {
return nil, fmt.Errorf("failed to retrieve forum posts since timestamp: %w", err)
}
return forums, nil
}
// Save updates an existing forum post in the database
func (f *Forum) Save() error {
if f.ID == 0 {
return fmt.Errorf("cannot save forum post without ID")
}
query := `UPDATE forum SET posted = ?, last_post = ?, author = ?, parent = ?, replies = ?, title = ?, content = ? WHERE id = ?`
return f.db.Exec(query, f.Posted, f.LastPost, f.Author, f.Parent, f.Replies, f.Title, f.Content, f.ID)
}
// Delete removes the forum post from the database
func (f *Forum) Delete() error {
if f.ID == 0 {
return fmt.Errorf("cannot delete forum post without ID")
}
query := "DELETE FROM forum WHERE id = ?"
return f.db.Exec(query, f.ID)
}
// PostedTime returns the posted timestamp as a time.Time
func (f *Forum) PostedTime() time.Time {
return time.Unix(f.Posted, 0)
}
// LastPostTime returns the last post timestamp as a time.Time
func (f *Forum) LastPostTime() time.Time {
return time.Unix(f.LastPost, 0)
}
// SetPostedTime sets the posted timestamp from a time.Time
func (f *Forum) SetPostedTime(t time.Time) {
f.Posted = t.Unix()
}
// SetLastPostTime sets the last post timestamp from a time.Time
func (f *Forum) SetLastPostTime(t time.Time) {
f.LastPost = t.Unix()
}
// IsThread returns true if this is a top-level thread (parent = 0)
func (f *Forum) IsThread() bool {
return f.Parent == 0
}
// IsReply returns true if this is a reply to another post (parent > 0)
func (f *Forum) IsReply() bool {
return f.Parent > 0
}
// HasReplies returns true if this post has replies
func (f *Forum) HasReplies() bool {
return f.Replies > 0
}
// IsRecentActivity returns true if there has been activity within the last 24 hours
func (f *Forum) IsRecentActivity() bool {
return time.Since(f.LastPostTime()) < 24*time.Hour
}
// ActivityAge returns how long ago the last activity occurred
func (f *Forum) ActivityAge() time.Duration {
return time.Since(f.LastPostTime())
}
// PostAge returns how long ago the post was originally made
func (f *Forum) PostAge() time.Duration {
return time.Since(f.PostedTime())
}
// IsAuthor returns true if the given user ID is the author of this post
func (f *Forum) IsAuthor(userID int) bool {
return f.Author == userID
}
// Preview returns a truncated version of the content for previews
func (f *Forum) Preview(maxLength int) string {
if len(f.Content) <= maxLength {
return f.Content
}
if maxLength < 3 {
return f.Content[:maxLength]
}
return f.Content[:maxLength-3] + "..."
}
// WordCount returns the number of words in the content
func (f *Forum) WordCount() int {
if f.Content == "" {
return 0
}
// Simple word count by splitting on whitespace
words := 0
inWord := false
for _, char := range f.Content {
if char == ' ' || char == '\t' || char == '\n' || char == '\r' {
if inWord {
words++
inWord = false
}
} else {
inWord = true
}
}
if inWord {
words++
}
return words
}
// Length returns the character length of the content
func (f *Forum) Length() int {
return len(f.Content)
}
// Contains returns true if the title or content contains the given term (case-insensitive)
func (f *Forum) Contains(term string) bool {
lowerTerm := strings.ToLower(term)
return strings.Contains(strings.ToLower(f.Title), lowerTerm) ||
strings.Contains(strings.ToLower(f.Content), lowerTerm)
}
// UpdateLastPost updates the last_post timestamp to current time
func (f *Forum) UpdateLastPost() {
f.LastPost = time.Now().Unix()
}
// IncrementReplies increments the reply count
func (f *Forum) IncrementReplies() {
f.Replies++
}
// DecrementReplies decrements the reply count (minimum 0)
func (f *Forum) DecrementReplies() {
if f.Replies > 0 {
f.Replies--
}
}
// GetReplies retrieves all direct replies to this post
func (f *Forum) GetReplies() ([]*Forum, error) {
return ByParent(f.db, f.ID)
}
// GetThread retrieves the parent thread (if this is a reply) or returns self (if this is a thread)
func (f *Forum) GetThread() (*Forum, error) {
if f.IsThread() {
return f, nil
}
return Find(f.db, f.Parent)
}

View File

@ -0,0 +1,665 @@
package forum
import (
"os"
"testing"
"time"
"dk/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
testDB := "test_forum.db"
t.Cleanup(func() {
os.Remove(testDB)
})
db, err := database.Open(testDB)
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create forum table
createTable := `CREATE TABLE forum (
id INTEGER PRIMARY KEY AUTOINCREMENT,
posted INTEGER NOT NULL DEFAULT (unixepoch()),
last_post INTEGER NOT NULL DEFAULT (unixepoch()),
author INTEGER NOT NULL,
parent INTEGER NOT NULL DEFAULT 0,
replies INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
content TEXT NOT NULL
)`
if err := db.Exec(createTable); err != nil {
t.Fatalf("Failed to create forum table: %v", err)
}
// Insert test data with specific timestamps for predictable testing
now := time.Now().Unix()
testForum := `INSERT INTO forum (posted, last_post, author, parent, replies, title, content) VALUES
(?, ?, 1, 0, 2, 'Welcome to the Game!', 'This is the first thread about our awesome game.'),
(?, ?, 2, 1, 0, 'Re: Welcome to the Game!', 'Thanks! I am excited to start playing.'),
(?, ?, 3, 1, 0, 'Re: Welcome to the Game!', 'Great game so far, loving the mechanics!'),
(?, ?, 1, 0, 1, 'Bug Reports', 'Please report any bugs you find here.'),
(?, ?, 2, 4, 0, 'Re: Bug Reports', 'Found a small issue with spell casting.'),
(?, ?, 3, 0, 0, 'Strategy Discussion', 'Let us discuss optimal character builds and strategies.')`
timestamps := []interface{}{
now - 86400*7, now - 86400*1, // Thread 1, last activity 1 day ago
now - 86400*6, now - 86400*6, // Reply 1
now - 86400*1, now - 86400*1, // Reply 2 (most recent activity on thread 1)
now - 86400*3, now - 86400*2, // Thread 2, last activity 2 days ago
now - 86400*2, now - 86400*2, // Reply to thread 2 (most recent activity on thread 2)
now - 3600*2, now - 3600*2, // Thread 3, 2 hours ago (most recent)
}
if err := db.Exec(testForum, timestamps...); err != nil {
t.Fatalf("Failed to insert test forum data: %v", err)
}
return db
}
func TestFind(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test finding existing forum post
post, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find forum post: %v", err)
}
if post.ID != 1 {
t.Errorf("Expected ID 1, got %d", post.ID)
}
if post.Author != 1 {
t.Errorf("Expected author 1, got %d", post.Author)
}
if post.Parent != 0 {
t.Errorf("Expected parent 0, got %d", post.Parent)
}
if post.Replies != 2 {
t.Errorf("Expected replies 2, got %d", post.Replies)
}
if post.Title != "Welcome to the Game!" {
t.Errorf("Expected title 'Welcome to the Game!', got '%s'", post.Title)
}
if post.Content != "This is the first thread about our awesome game." {
t.Errorf("Expected specific content, got '%s'", post.Content)
}
if post.Posted == 0 {
t.Error("Expected non-zero posted timestamp")
}
if post.LastPost == 0 {
t.Error("Expected non-zero last_post timestamp")
}
// Test finding non-existent forum post
_, err = Find(db, 999)
if err == nil {
t.Error("Expected error when finding non-existent forum post")
}
}
func TestAll(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
posts, err := All(db)
if err != nil {
t.Fatalf("Failed to get all forum posts: %v", err)
}
if len(posts) != 6 {
t.Errorf("Expected 6 forum posts, got %d", len(posts))
}
// Check ordering (by last_post DESC)
if len(posts) >= 2 {
if posts[0].LastPost < posts[1].LastPost {
t.Error("Expected posts to be ordered by last_post (newest first)")
}
}
// First post should be the most recent activity (2 hours ago)
if posts[0].Title != "Strategy Discussion" {
t.Errorf("Expected newest activity to be 'Strategy Discussion', got '%s'", posts[0].Title)
}
}
func TestThreads(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
threads, err := Threads(db)
if err != nil {
t.Fatalf("Failed to get forum threads: %v", err)
}
if len(threads) != 3 {
t.Errorf("Expected 3 threads, got %d", len(threads))
}
// Verify all are threads (parent = 0)
for _, thread := range threads {
if thread.Parent != 0 {
t.Errorf("Expected thread to have parent 0, got %d", thread.Parent)
}
if !thread.IsThread() {
t.Errorf("Expected IsThread() to return true for thread %d", thread.ID)
}
if thread.IsReply() {
t.Errorf("Expected IsReply() to return false for thread %d", thread.ID)
}
}
// Check ordering (by last_post DESC)
if len(threads) >= 2 {
if threads[0].LastPost < threads[1].LastPost {
t.Error("Expected threads to be ordered by last activity")
}
}
}
func TestByParent(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test replies to thread 1
replies, err := ByParent(db, 1)
if err != nil {
t.Fatalf("Failed to get replies: %v", err)
}
if len(replies) != 2 {
t.Errorf("Expected 2 replies to thread 1, got %d", len(replies))
}
// Verify all are replies to thread 1
for _, reply := range replies {
if reply.Parent != 1 {
t.Errorf("Expected reply to have parent 1, got %d", reply.Parent)
}
if !reply.IsReply() {
t.Errorf("Expected IsReply() to return true for reply %d", reply.ID)
}
if reply.IsThread() {
t.Errorf("Expected IsThread() to return false for reply %d", reply.ID)
}
}
// Check ordering (by posted ASC for replies)
if len(replies) == 2 {
if replies[0].Posted > replies[1].Posted {
t.Error("Expected replies to be ordered by posted time (oldest first)")
}
}
// Test no replies case
noReplies, err := ByParent(db, 6) // Thread 3 has no replies
if err != nil {
t.Fatalf("Failed to get replies for thread with no replies: %v", err)
}
if len(noReplies) != 0 {
t.Errorf("Expected 0 replies for thread 3, got %d", len(noReplies))
}
}
func TestByAuthor(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test posts by author 1
author1Posts, err := ByAuthor(db, 1)
if err != nil {
t.Fatalf("Failed to get posts by author: %v", err)
}
if len(author1Posts) != 2 {
t.Errorf("Expected 2 posts by author 1, got %d", len(author1Posts))
}
// Verify all posts are by author 1
for _, post := range author1Posts {
if post.Author != 1 {
t.Errorf("Expected author 1, got %d", post.Author)
}
}
// Check ordering (by posted DESC)
if len(author1Posts) == 2 {
if author1Posts[0].Posted < author1Posts[1].Posted {
t.Error("Expected posts to be ordered by posted time (newest first)")
}
}
// Test author with no posts
noPosts, err := ByAuthor(db, 999)
if err != nil {
t.Fatalf("Failed to query non-existent author: %v", err)
}
if len(noPosts) != 0 {
t.Errorf("Expected 0 posts by non-existent author, got %d", len(noPosts))
}
}
func TestRecent(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test getting 3 most recent posts
recentPosts, err := Recent(db, 3)
if err != nil {
t.Fatalf("Failed to get recent forum posts: %v", err)
}
if len(recentPosts) != 3 {
t.Errorf("Expected 3 recent posts, got %d", len(recentPosts))
}
// Check ordering (by last_post DESC)
if len(recentPosts) >= 2 {
if recentPosts[0].LastPost < recentPosts[1].LastPost {
t.Error("Expected recent posts to be ordered by last activity")
}
}
// Test getting more posts than exist
allRecentPosts, err := Recent(db, 10)
if err != nil {
t.Fatalf("Failed to get recent posts with high limit: %v", err)
}
if len(allRecentPosts) != 6 {
t.Errorf("Expected 6 posts (all available), got %d", len(allRecentPosts))
}
}
func TestSearch(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test searching for "game"
gamePosts, err := Search(db, "game")
if err != nil {
t.Fatalf("Failed to search forum posts: %v", err)
}
expectedCount := 3 // Welcome thread title, content, and replies containing "game"
if len(gamePosts) != expectedCount {
t.Errorf("Expected %d posts containing 'game', got %d", expectedCount, len(gamePosts))
}
// Verify all posts contain the search term
for _, post := range gamePosts {
if !post.Contains("game") {
t.Errorf("Post '%s' does not contain search term 'game'", post.Title)
}
}
// Test case insensitive search
gamePostsUpper, err := Search(db, "GAME")
if err != nil {
t.Fatalf("Failed to search with uppercase: %v", err)
}
if len(gamePostsUpper) != expectedCount {
t.Error("Expected case insensitive search to find same results")
}
// Test search with no results
noResults, err := Search(db, "nonexistentterm")
if err != nil {
t.Fatalf("Failed to search for non-existent term: %v", err)
}
if len(noResults) != 0 {
t.Errorf("Expected 0 results for non-existent term, got %d", len(noResults))
}
}
func TestSince(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test posts with activity since 3 days ago
threeDaysAgo := time.Now().AddDate(0, 0, -3).Unix()
recentPosts, err := Since(db, threeDaysAgo)
if err != nil {
t.Fatalf("Failed to get posts since timestamp: %v", err)
}
// Should get posts with last_post within last 3 days (includes replies)
expectedCount := 5 // Thread 1 (1 day ago), Reply 2 to Thread 1, Thread 2 (2 days ago), Reply to Thread 2, Thread 3 (2 hours ago)
if len(recentPosts) != expectedCount {
t.Errorf("Expected %d posts with activity since 3 days ago, got %d", expectedCount, len(recentPosts))
}
// Verify all posts have last_post since the timestamp
for _, post := range recentPosts {
if post.LastPost < threeDaysAgo {
t.Errorf("Post with last_post %d is before the 'since' timestamp %d", post.LastPost, threeDaysAgo)
}
}
// Test with future timestamp (should return no posts)
futurePosts, err := Since(db, time.Now().Add(time.Hour).Unix())
if err != nil {
t.Fatalf("Failed to query future timestamp: %v", err)
}
if len(futurePosts) != 0 {
t.Errorf("Expected 0 posts since future timestamp, got %d", len(futurePosts))
}
}
func TestBuilder(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Create new thread using builder
testTime := time.Now()
post, err := NewBuilder(db).
WithAuthor(5).
WithTitle("Test Thread").
WithContent("This is a test thread created with the builder").
WithPostedTime(testTime).
WithLastPostTime(testTime).
AsThread().
Create()
if err != nil {
t.Fatalf("Failed to create forum post with builder: %v", err)
}
if post.ID == 0 {
t.Error("Expected non-zero ID after creation")
}
if post.Author != 5 {
t.Errorf("Expected author 5, got %d", post.Author)
}
if post.Title != "Test Thread" {
t.Errorf("Expected title 'Test Thread', got '%s'", post.Title)
}
if post.Content != "This is a test thread created with the builder" {
t.Errorf("Expected specific content, got '%s'", post.Content)
}
if post.Posted != testTime.Unix() {
t.Errorf("Expected posted time %d, got %d", testTime.Unix(), post.Posted)
}
if post.Parent != 0 {
t.Errorf("Expected parent 0 (thread), got %d", post.Parent)
}
if post.Replies != 0 {
t.Errorf("Expected replies 0, got %d", post.Replies)
}
// Create reply using builder
reply, err := NewBuilder(db).
WithAuthor(6).
WithTitle("Re: Test Thread").
WithContent("This is a reply to the test thread").
AsReply(post.ID).
Create()
if err != nil {
t.Fatalf("Failed to create reply with builder: %v", err)
}
if reply.Parent != post.ID {
t.Errorf("Expected parent %d, got %d", post.ID, reply.Parent)
}
if !reply.IsReply() {
t.Error("Expected reply to be identified as reply")
}
// Verify posts were saved to database
foundPost, err := Find(db, post.ID)
if err != nil {
t.Fatalf("Failed to find created post: %v", err)
}
if foundPost.Title != "Test Thread" {
t.Errorf("Created post not found in database")
}
// Test builder with default timestamp
defaultPost, err := NewBuilder(db).
WithAuthor(7).
WithTitle("Default Time Post").
WithContent("Post with default timestamps").
Create()
if err != nil {
t.Fatalf("Failed to create post with default timestamp: %v", err)
}
// Should have recent timestamps (within last minute)
if time.Since(defaultPost.PostedTime()) > time.Minute {
t.Error("Expected default posted timestamp to be recent")
}
if time.Since(defaultPost.LastPostTime()) > time.Minute {
t.Error("Expected default last_post timestamp to be recent")
}
}
func TestSave(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
post, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find forum post: %v", err)
}
// Modify post
post.Title = "Updated Welcome Thread"
post.Content = "This content has been updated by moderator"
post.Replies = 3
post.UpdateLastPost()
// Save changes
err = post.Save()
if err != nil {
t.Fatalf("Failed to save forum post: %v", err)
}
// Verify changes were saved
updatedPost, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find updated post: %v", err)
}
if updatedPost.Title != "Updated Welcome Thread" {
t.Errorf("Expected updated title 'Updated Welcome Thread', got '%s'", updatedPost.Title)
}
if updatedPost.Content != "This content has been updated by moderator" {
t.Errorf("Expected updated content, got '%s'", updatedPost.Content)
}
if updatedPost.Replies != 3 {
t.Errorf("Expected updated replies 3, got %d", updatedPost.Replies)
}
}
func TestDelete(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
post, err := Find(db, 1)
if err != nil {
t.Fatalf("Failed to find forum post: %v", err)
}
// Delete post
err = post.Delete()
if err != nil {
t.Fatalf("Failed to delete forum post: %v", err)
}
// Verify post was deleted
_, err = Find(db, 1)
if err == nil {
t.Error("Expected error when finding deleted post")
}
}
func TestUtilityMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
post, _ := Find(db, 1)
// Test time methods
postedTime := post.PostedTime()
if postedTime.IsZero() {
t.Error("Expected non-zero posted time")
}
lastPostTime := post.LastPostTime()
if lastPostTime.IsZero() {
t.Error("Expected non-zero last post time")
}
// Test SetPostedTime and SetLastPostTime
newTime := time.Now().Add(-2 * time.Hour)
post.SetPostedTime(newTime)
post.SetLastPostTime(newTime)
if post.Posted != newTime.Unix() {
t.Errorf("Expected posted timestamp %d, got %d", newTime.Unix(), post.Posted)
}
if post.LastPost != newTime.Unix() {
t.Errorf("Expected last_post timestamp %d, got %d", newTime.Unix(), post.LastPost)
}
// Test activity age methods
activityAge := post.ActivityAge()
if activityAge < 0 {
t.Error("Expected positive activity age")
}
postAge := post.PostAge()
if postAge < 0 {
t.Error("Expected positive post age")
}
// Test IsRecentActivity
post.UpdateLastPost() // Set to now
if !post.IsRecentActivity() {
t.Error("Expected post with current timestamp to have recent activity")
}
// Test IsAuthor
if !post.IsAuthor(post.Author) {
t.Error("Expected IsAuthor to return true for correct author")
}
if post.IsAuthor(999) {
t.Error("Expected IsAuthor to return false for incorrect author")
}
// Test HasReplies
if !post.HasReplies() {
t.Error("Expected post with replies > 0 to HasReplies")
}
// Test Preview
longContent := "This is a very long forum post content that should be truncated when preview is called for display purposes"
post.Content = longContent
preview := post.Preview(20)
if len(preview) > 20 {
t.Errorf("Expected preview length <= 20, got %d", len(preview))
}
if preview[len(preview)-3:] != "..." {
t.Error("Expected preview to end with ellipsis")
}
shortPreview := post.Preview(200) // Longer than content
if shortPreview != longContent {
t.Error("Expected short content to not be truncated")
}
// Test WordCount
post.Content = "This is a test with five words"
wordCount := post.WordCount()
if wordCount != 7 {
t.Errorf("Expected 7 words, got %d", wordCount)
}
// Test Length
expectedLength := len(post.Content)
if post.Length() != expectedLength {
t.Errorf("Expected length %d, got %d", expectedLength, post.Length())
}
// Test Contains
if !post.Contains("test") {
t.Error("Expected post to contain 'test'")
}
if !post.Contains("TEST") { // Case insensitive
t.Error("Expected Contains to be case insensitive")
}
if post.Contains("nonexistent") {
t.Error("Expected post not to contain 'nonexistent'")
}
// Test reply count methods
originalReplies := post.Replies
post.IncrementReplies()
if post.Replies != originalReplies+1 {
t.Errorf("Expected replies to be incremented to %d, got %d", originalReplies+1, post.Replies)
}
post.DecrementReplies()
if post.Replies != originalReplies {
t.Errorf("Expected replies to be decremented back to %d, got %d", originalReplies, post.Replies)
}
// Test DecrementReplies with 0 replies
post.Replies = 0
post.DecrementReplies()
if post.Replies != 0 {
t.Errorf("Expected replies to stay at 0 when decrementing from 0, got %d", post.Replies)
}
}
func TestRelationshipMethods(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Test GetReplies on a thread
thread, _ := Find(db, 1) // Thread with 2 replies
replies, err := thread.GetReplies()
if err != nil {
t.Fatalf("Failed to get replies: %v", err)
}
if len(replies) != 2 {
t.Errorf("Expected 2 replies, got %d", len(replies))
}
// Test GetThread on a reply
reply, _ := Find(db, 2) // Reply to thread 1
parentThread, err := reply.GetThread()
if err != nil {
t.Fatalf("Failed to get parent thread: %v", err)
}
if parentThread.ID != 1 {
t.Errorf("Expected parent thread ID 1, got %d", parentThread.ID)
}
// Test GetThread on a thread (should return self)
threadSelf, err := thread.GetThread()
if err != nil {
t.Fatalf("Failed to get thread (self): %v", err)
}
if threadSelf.ID != thread.ID {
t.Errorf("Expected GetThread on thread to return self, got ID %d", threadSelf.ID)
}
}