Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 121 additions & 9 deletions backend/controllers/list_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ func NewListController(router *gin.Engine, service services.ListService, recomme
group.POST("/:id/movies/:movieId/comments", c.authMiddleware.Handler(), c.createComment)
group.PATCH("/:id/movies/:movieId/comments/:commentId", c.authMiddleware.Handler(), c.updateComment)
group.DELETE("/:id/movies/:movieId/comments/:commentId", c.authMiddleware.Handler(), c.deleteComment)
// User home routes
userGroup := router.Group("/api/users")
userGroup.GET("/stats", c.authMiddleware.Handler(), c.getUserStats)
userGroup.GET("/activity", c.authMiddleware.Handler(), c.getUserActivity)
return c
}

Expand Down Expand Up @@ -138,19 +142,45 @@ func (c *ListController) list(ctx *gin.Context) {
return
}

listIDs := make([]int64, 0, len(memberships))
for _, m := range memberships {
listIDs = append(listIDs, m.ListID)
}
posterURLs, memberPreviews, lastActivityMap, enrichErr := c.service.FetchListEnrichments(listIDs)
if enrichErr != nil {
posterURLs = map[int64][]string{}
memberPreviews = map[int64][]daos.MemberPreview{}
lastActivityMap = map[int64]time.Time{}
}

lists := make([]gin.H, 0, len(memberships))
for _, m := range memberships {
l := m.List
lastActivity := l.UpdatedAt
if t, ok := lastActivityMap[l.ID]; ok && t.After(lastActivity) {
lastActivity = t
}
membersPayload := make([]gin.H, 0)
for _, mp := range memberPreviews[l.ID] {
membersPayload = append(membersPayload, gin.H{
"user_id": mp.UserID,
"username": mp.Username,
"avatar_url": mp.AvatarURL,
})
}
lists = append(lists, gin.H{
"id": l.ID,
"name": l.Name,
"description": l.Description,
"invite_code": l.InviteCode,
"your_role": m.Role,
"created_at": l.CreatedAt,
"updated_at": l.UpdatedAt,
"member_count": memberCounts[l.ID],
"movie_count": movieCounts[l.ID],
"id": l.ID,
"name": l.Name,
"description": l.Description,
"invite_code": l.InviteCode,
"your_role": m.Role,
"created_at": l.CreatedAt,
"updated_at": l.UpdatedAt,
"member_count": memberCounts[l.ID],
"movie_count": movieCounts[l.ID],
"poster_urls": posterURLs[l.ID],
"members": membersPayload,
"last_activity_at": lastActivity,
})
}

Expand Down Expand Up @@ -1783,6 +1813,88 @@ func (c *ListController) deleteComment(ctx *gin.Context) {
})
}

func (c *ListController) getUserStats(ctx *gin.Context) {
rawClaims, _ := ctx.Get("auth_claims")
claims := rawClaims.(jwt.MapClaims)
sub, _ := claims["sub"].(string)
userID, err := strconv.ParseInt(sub, 10, 64)
if err != nil {
respondTokenInvalid(ctx)
return
}
stats, err := c.service.GetUserStats(userID)
if err != nil {
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to fetch stats",
"code": "INTERNAL_ERROR",
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, stats)
}

func (c *ListController) getUserActivity(ctx *gin.Context) {
rawClaims, _ := ctx.Get("auth_claims")
claims := rawClaims.(jwt.MapClaims)
sub, _ := claims["sub"].(string)
userID, err := strconv.ParseInt(sub, 10, 64)
if err != nil {
respondTokenInvalid(ctx)
return
}
limit := 20
if v := ctx.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
if n > 50 {
n = 50
}
limit = n
}
}
items, err := c.service.GetUserActivity(userID, limit)
if err != nil {
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to fetch activity",
"code": "INTERNAL_ERROR",
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
return
}
payload := make([]gin.H, 0, len(items))
for _, item := range items {
entry := gin.H{
"type": item.Type,
"timestamp": item.Timestamp,
"list_id": item.ListID,
"list_name": item.ListName,
}
if item.MovieID != nil {
entry["movie_id"] = item.MovieID
}
if item.MovieTitle != nil {
entry["movie_title"] = item.MovieTitle
}
if item.MoviePosterPath != nil {
entry["movie_poster_url"] = item.MoviePosterPath
}
if item.UserID != nil {
entry["user_id"] = item.UserID
entry["username"] = item.Username
entry["avatar_url"] = item.AvatarURL
}
payload = append(payload, entry)
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"activity": payload,
"count": len(payload),
})
}

func (c *ListController) getRecommendations(ctx *gin.Context) {
idParam := ctx.Param("id")
listID, err := strconv.ParseInt(idParam, 10, 64)
Expand Down
211 changes: 211 additions & 0 deletions backend/daos/list_dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ import (
"gorm.io/gorm/clause"
)

// MemberPreview is a lightweight user struct returned in batch list enrichment.
type MemberPreview struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
AvatarURL *string `json:"avatar_url"`
}

// ActivityItem represents one event in the user's home-page activity feed.
type ActivityItem struct {
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`
ListID int64 `json:"list_id"`
ListName string `json:"list_name"`
MovieID *int64 `json:"movie_id,omitempty"`
MovieTitle *string `json:"movie_title,omitempty"`
MoviePosterPath *string `json:"movie_poster_path,omitempty"`
UserID *int64 `json:"user_id,omitempty"`
Username *string `json:"username,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
}

type MovieListDAO interface {
InviteCodeExists(code string) (bool, error)
CreateWithOwner(list *models.MovieList, ownerUserID int64) error
Expand Down Expand Up @@ -42,6 +63,12 @@ type MovieListDAO interface {
FindCommentByID(commentID int64) (*models.Comment, error)
UpdateComment(commentID int64, content string) (*models.Comment, error)
DeleteComment(commentID int64) error
// Home enrichment methods
FetchPostersBatch(listIDs []int64) (map[int64][]string, error)
FetchMembersBatch(listIDs []int64) (map[int64][]MemberPreview, error)
FetchLastActivityBatch(listIDs []int64) (map[int64]time.Time, error)
CountWatchedThisMonth(userID int64) (int64, error)
FetchRecentActivity(userID int64, limit int) ([]ActivityItem, error)
}

type movieListDAO struct {
Expand Down Expand Up @@ -560,3 +587,187 @@ func (d *movieListDAO) UpdateComment(commentID int64, content string) (*models.C
func (d *movieListDAO) DeleteComment(commentID int64) error {
return d.db.Delete(&models.Comment{}, commentID).Error
}

func (d *movieListDAO) FetchPostersBatch(listIDs []int64) (map[int64][]string, error) {
result := make(map[int64][]string)
if len(listIDs) == 0 {
return result, nil
}
type posterRow struct {
ListID int64 `gorm:"column:list_id"`
PosterPath string `gorm:"column:poster_path"`
}
var rows []posterRow
if err := d.db.Table("list_movies lm").
Select("lm.list_id AS list_id, m.poster_path AS poster_path").
Joins("JOIN movies m ON m.id = lm.movie_id").
Where("lm.list_id IN ? AND m.poster_path IS NOT NULL AND m.poster_path != ''", listIDs).
Order("lm.list_id, lm.display_order ASC, lm.added_at DESC").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
if len(result[r.ListID]) < 4 {
result[r.ListID] = append(result[r.ListID], r.PosterPath)
}
}
return result, nil
}

func (d *movieListDAO) FetchMembersBatch(listIDs []int64) (map[int64][]MemberPreview, error) {
result := make(map[int64][]MemberPreview)
if len(listIDs) == 0 {
return result, nil
}
type memberRow struct {
ListID int64 `gorm:"column:list_id"`
UserID int64 `gorm:"column:user_id"`
Username string `gorm:"column:username"`
AvatarURL *string `gorm:"column:avatar_url"`
}
var rows []memberRow
if err := d.db.Table("list_members lm").
Select("lm.list_id AS list_id, u.id AS user_id, u.username AS username, u.avatar_url AS avatar_url").
Joins("JOIN users u ON u.id = lm.user_id").
Where("lm.list_id IN ?", listIDs).
Order("lm.list_id, lm.added_at ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
if len(result[r.ListID]) < 5 {
result[r.ListID] = append(result[r.ListID], MemberPreview{
UserID: r.UserID,
Username: r.Username,
AvatarURL: r.AvatarURL,
})
}
}
return result, nil
}

func (d *movieListDAO) FetchLastActivityBatch(listIDs []int64) (map[int64]time.Time, error) {
result := make(map[int64]time.Time)
if len(listIDs) == 0 {
return result, nil
}
type activityTimeRow struct {
ListID int64 `gorm:"column:list_id"`
LastAdded *time.Time `gorm:"column:last_added"`
LastWatched *time.Time `gorm:"column:last_watched"`
}
var rows []activityTimeRow
if err := d.db.Table("list_movies").
Select("list_id, MAX(added_at) AS last_added, MAX(watched_at) AS last_watched").
Where("list_id IN ?", listIDs).
Group("list_id").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
var t time.Time
if r.LastAdded != nil && r.LastAdded.After(t) {
t = *r.LastAdded
}
if r.LastWatched != nil && r.LastWatched.After(t) {
t = *r.LastWatched
}
if !t.IsZero() {
result[r.ListID] = t
}
}
return result, nil
}

func (d *movieListDAO) CountWatchedThisMonth(userID int64) (int64, error) {
now := time.Now()
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
var count int64
if err := d.db.Table("list_movies lm").
Joins("JOIN list_members mem ON mem.list_id = lm.list_id AND mem.user_id = ?", userID).
Joins("JOIN movie_lists ml ON ml.id = lm.list_id AND ml.deleted_at IS NULL").
Where("lm.status = ? AND lm.watched_at >= ?", string(models.StatusWatched), firstOfMonth).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

func (d *movieListDAO) FetchRecentActivity(userID int64, limit int) ([]ActivityItem, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
sql := `
(SELECT 'movie_added' AS activity_type, lm.added_at AS ts, lm.list_id, ml.name AS list_name,
lm.movie_id, m.title AS movie_title, m.poster_path AS movie_poster_path,
u.id AS actor_user_id, u.username AS actor_username, u.avatar_url AS actor_avatar_url
FROM list_movies lm
JOIN movie_lists ml ON ml.id = lm.list_id AND ml.deleted_at IS NULL
JOIN movies m ON m.id = lm.movie_id
LEFT JOIN users u ON u.id = lm.added_by
WHERE EXISTS (SELECT 1 FROM list_members me WHERE me.list_id = lm.list_id AND me.user_id = ?))
UNION ALL
(SELECT 'movie_watched' AS activity_type, lm.watched_at AS ts, lm.list_id, ml.name AS list_name,
lm.movie_id, m.title AS movie_title, m.poster_path AS movie_poster_path,
NULL AS actor_user_id, NULL AS actor_username, NULL AS actor_avatar_url
FROM list_movies lm
JOIN movie_lists ml ON ml.id = lm.list_id AND ml.deleted_at IS NULL
JOIN movies m ON m.id = lm.movie_id
WHERE lm.watched_at IS NOT NULL AND lm.status = 'watched'
AND EXISTS (SELECT 1 FROM list_members me WHERE me.list_id = lm.list_id AND me.user_id = ?))
UNION ALL
(SELECT 'comment' AS activity_type, c.created_at AS ts, c.list_id, ml.name AS list_name,
c.movie_id, m.title AS movie_title, m.poster_path AS movie_poster_path,
u.id AS actor_user_id, u.username AS actor_username, u.avatar_url AS actor_avatar_url
FROM comments c
JOIN movie_lists ml ON ml.id = c.list_id AND ml.deleted_at IS NULL
JOIN movies m ON m.id = c.movie_id
JOIN users u ON u.id = c.user_id
WHERE EXISTS (SELECT 1 FROM list_members me WHERE me.list_id = c.list_id AND me.user_id = ?))
UNION ALL
(SELECT 'member_joined' AS activity_type, lm.added_at AS ts, lm.list_id, ml.name AS list_name,
NULL AS movie_id, NULL AS movie_title, NULL AS movie_poster_path,
u.id AS actor_user_id, u.username AS actor_username, u.avatar_url AS actor_avatar_url
FROM list_members lm
JOIN movie_lists ml ON ml.id = lm.list_id AND ml.deleted_at IS NULL
JOIN users u ON u.id = lm.user_id
WHERE EXISTS (SELECT 1 FROM list_members me WHERE me.list_id = lm.list_id AND me.user_id = ?))
ORDER BY ts DESC
LIMIT ?
`
type activityRow struct {
ActivityType string `gorm:"column:activity_type"`
Ts time.Time `gorm:"column:ts"`
ListID int64 `gorm:"column:list_id"`
ListName string `gorm:"column:list_name"`
MovieID *int64 `gorm:"column:movie_id"`
MovieTitle *string `gorm:"column:movie_title"`
MoviePosterPath *string `gorm:"column:movie_poster_path"`
ActorUserID *int64 `gorm:"column:actor_user_id"`
ActorUsername *string `gorm:"column:actor_username"`
ActorAvatarURL *string `gorm:"column:actor_avatar_url"`
}
var rows []activityRow
if err := d.db.Raw(sql, userID, userID, userID, userID, limit).Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]ActivityItem, 0, len(rows))
for _, r := range rows {
items = append(items, ActivityItem{
Type: r.ActivityType,
Timestamp: r.Ts,
ListID: r.ListID,
ListName: r.ListName,
MovieID: r.MovieID,
MovieTitle: r.MovieTitle,
MoviePosterPath: r.MoviePosterPath,
UserID: r.ActorUserID,
Username: r.ActorUsername,
AvatarURL: r.ActorAvatarURL,
})
}
return items, nil
}
Loading
Loading