Skip to content
Open
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
116 changes: 116 additions & 0 deletions backend/internal/repository/announcement_read_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package repository

import (
"context"
"fmt"
"strings"
"time"

dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/announcementread"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)

Expand Down Expand Up @@ -85,3 +88,116 @@ func (r *announcementReadRepository) CountByAnnouncementID(ctx context.Context,
}
return int64(count), nil
}

// ListUsersOrderedByReadAt joins users with announcement_reads via LEFT JOIN and
// returns a paginated, search-filtered list ordered by ar.read_at. Unread users
// (NULL read_at) sort to the end for DESC and to the start for ASC. The
// composite index (announcement_id, read_at DESC NULLS LAST) introduced in
// migration 146 makes the ordering index-driven.
func (r *announcementReadRepository) ListUsersOrderedByReadAt(
ctx context.Context,
announcementID int64,
params pagination.PaginationParams,
search string,
) ([]service.AnnouncementReadUserRow, *pagination.PaginationResult, error) {
client := clientFromContext(ctx, r.client)

trimmedSearch := strings.TrimSpace(search)

// 1) Count total matching users (search-filtered; independent of read_at).
var (
countSQL string
countArgs []any
)
if trimmedSearch == "" {
countSQL = `SELECT COUNT(*) FROM users`
} else {
countSQL = `SELECT COUNT(*) FROM users u WHERE u.email ILIKE $1 OR u.username ILIKE $1`
countArgs = []any{"%" + trimmedSearch + "%"}
}
var total int64
{
countRows, err := client.QueryContext(ctx, countSQL, countArgs...)
if err != nil {
return nil, nil, fmt.Errorf("count users for read-status: %w", err)
}
if countRows.Next() {
if err := countRows.Scan(&total); err != nil {
_ = countRows.Close()
return nil, nil, fmt.Errorf("scan user count for read-status: %w", err)
}
}
if err := countRows.Err(); err != nil {
_ = countRows.Close()
return nil, nil, fmt.Errorf("read user count for read-status: %w", err)
}
if err := countRows.Close(); err != nil {
return nil, nil, fmt.Errorf("close user count rows: %w", err)
}
}

// 2) ORDER BY direction with NULL handling for unread users (LEFT JOIN NULL).
order := strings.ToLower(strings.TrimSpace(params.SortOrder))
if order != pagination.SortOrderAsc {
order = pagination.SortOrderDesc
}
nullsPos := "LAST"
if order == pagination.SortOrderAsc {
nullsPos = "FIRST"
}

page := params.Page
if page <= 0 {
page = 1
}
pageSize := params.PageSize
if pageSize <= 0 {
pageSize = 20
}

// 3) Build list query with positional args. announcementID is always $1;
// optional search is $2 (when present); LIMIT/OFFSET follow.
args := []any{announcementID}
searchClause := ""
if trimmedSearch != "" {
args = append(args, "%"+trimmedSearch+"%")
searchClause = fmt.Sprintf(" AND (u.email ILIKE $%d OR u.username ILIKE $%d)", len(args), len(args))
}
args = append(args, pageSize, (page-1)*pageSize)
limitPlaceholder := len(args) - 1
offsetPlaceholder := len(args)

listSQL := fmt.Sprintf(`
SELECT u.id,
COALESCE(u.email, ''),
COALESCE(u.username, ''),
COALESCE(u.balance, 0)::double precision,
ar.read_at
FROM users u
LEFT JOIN announcement_reads ar
ON ar.user_id = u.id AND ar.announcement_id = $1
WHERE TRUE%s
ORDER BY ar.read_at %s NULLS %s, u.id ASC
LIMIT $%d OFFSET $%d`, searchClause, strings.ToUpper(order), nullsPos, limitPlaceholder, offsetPlaceholder)

rows, err := client.QueryContext(ctx, listSQL, args...)
if err != nil {
return nil, nil, fmt.Errorf("list users ordered by read_at: %w", err)
}
defer func() { _ = rows.Close() }()

out := make([]service.AnnouncementReadUserRow, 0)
for rows.Next() {
var item service.AnnouncementReadUserRow
var readAt *time.Time
if err := rows.Scan(&item.UserID, &item.Email, &item.Username, &item.Balance, &readAt); err != nil {
return nil, nil, fmt.Errorf("scan read-status row: %w", err)
}
item.ReadAt = readAt
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
return out, paginationResultFromTotal(total, params), nil
}
28 changes: 28 additions & 0 deletions backend/internal/service/announcement.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,32 @@ type AnnouncementReadRepository interface {
GetReadMapByUser(ctx context.Context, userID int64, announcementIDs []int64) (map[int64]time.Time, error)
GetReadMapByUsers(ctx context.Context, announcementID int64, userIDs []int64) (map[int64]time.Time, error)
CountByAnnouncementID(ctx context.Context, announcementID int64) (int64, error)

// ListUsersOrderedByReadAt returns a paginated user list ordered by each user's
// read_at for the given announcement. Users who have not read the announcement
// are placed at the end for descending order and at the start for ascending
// order (so that the page can naturally surface either "latest readers" or
// "users who haven't read yet"). The search parameter is matched against
// users.email / users.username (case-insensitive substring).
//
// ListUsersOrderedByReadAt 返回按用户阅读该公告时间排序的分页用户列表。
// 未阅读的用户在降序时排到末尾,升序时排到开头 —— 便于一键查看"最近阅读人"
// 或"尚未阅读的人"。search 在 users.email / users.username 中做大小写不敏感
// 的子串匹配。
ListUsersOrderedByReadAt(
ctx context.Context,
announcementID int64,
params pagination.PaginationParams,
search string,
) ([]AnnouncementReadUserRow, *pagination.PaginationResult, error)
}

// AnnouncementReadUserRow is a flat row joining users with their per-announcement
// read_at. Used by the admin read-status page when sorting by read_at.
type AnnouncementReadUserRow struct {
UserID int64
Email string
Username string
Balance float64
ReadAt *time.Time
}
131 changes: 131 additions & 0 deletions backend/internal/service/announcement_read_at_sort_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package service

import (
"context"
"testing"
"time"

"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
)

// readByReadAtRepoStub captures the call to ListUsersOrderedByReadAt and returns
// canned rows. The other methods are required by the interface but not exercised
// by the read-at sort path under test.
type readByReadAtRepoStub struct {
called bool
gotAnnc int64
gotParam pagination.PaginationParams
gotSrch string
rows []AnnouncementReadUserRow
page *pagination.PaginationResult
}

func (r *readByReadAtRepoStub) MarkRead(context.Context, int64, int64, time.Time) error {
return nil
}
func (r *readByReadAtRepoStub) GetReadMapByUser(context.Context, int64, []int64) (map[int64]time.Time, error) {
return map[int64]time.Time{}, nil
}
func (r *readByReadAtRepoStub) GetReadMapByUsers(context.Context, int64, []int64) (map[int64]time.Time, error) {
return map[int64]time.Time{}, nil
}
func (r *readByReadAtRepoStub) CountByAnnouncementID(context.Context, int64) (int64, error) {
return 0, nil
}
func (r *readByReadAtRepoStub) ListUsersOrderedByReadAt(
_ context.Context,
announcementID int64,
params pagination.PaginationParams,
search string,
) ([]AnnouncementReadUserRow, *pagination.PaginationResult, error) {
r.called = true
r.gotAnnc = announcementID
r.gotParam = params
r.gotSrch = search
return r.rows, r.page, nil
}

// userSubRepoNoSubs is a UserSubscriptionRepository stub that always returns no
// active subscriptions (so eligibility computation in service degrades to the
// targeting's "no AnyOf -> match all" branch).
type userSubRepoNoSubs struct{ UserSubscriptionRepository }

func (userSubRepoNoSubs) ListActiveByUserID(context.Context, int64) ([]UserSubscription, error) {
return nil, nil
}

func TestListUserReadStatus_RoutesToReadAtPath(t *testing.T) {
t.Parallel()

annRepo := &announcementRepoStub{item: &Announcement{ID: 1, Title: "x", Content: "x"}}
readRepo := &readByReadAtRepoStub{
rows: []AnnouncementReadUserRow{
{UserID: 7, Email: "early@example.com", Username: "early", Balance: 0, ReadAt: timePtr(time.Unix(1000, 0))},
{UserID: 8, Email: "late@example.com", Username: "late", Balance: 0, ReadAt: timePtr(time.Unix(2000, 0))},
{UserID: 9, Email: "unread@example.com", Username: "unread", Balance: 0, ReadAt: nil},
},
page: &pagination.PaginationResult{Page: 1, PageSize: 20, Total: 3, Pages: 1},
}
svc := NewAnnouncementService(annRepo, readRepo, nil, userSubRepoNoSubs{})

params := pagination.PaginationParams{
Page: 1,
PageSize: 20,
SortBy: "read_at",
SortOrder: "asc",
}
out, page, err := svc.ListUserReadStatus(context.Background(), 1, params, "")
require.NoError(t, err)

require.True(t, readRepo.called, "expected service to dispatch to ListUsersOrderedByReadAt when sort_by=read_at")
require.Equal(t, int64(1), readRepo.gotAnnc)
require.Equal(t, params, readRepo.gotParam)

require.Len(t, out, 3)
require.Equal(t, int64(7), out[0].UserID)
require.Equal(t, int64(8), out[1].UserID)
require.Equal(t, int64(9), out[2].UserID)
require.NotNil(t, out[0].ReadAt)
require.Nil(t, out[2].ReadAt)

require.NotNil(t, page)
require.Equal(t, int64(3), page.Total)
}

func TestListUserReadStatus_DefaultPathWhenSortByOther(t *testing.T) {
t.Parallel()

annRepo := &announcementRepoStub{item: &Announcement{ID: 1, Title: "x", Content: "x"}}
readRepo := &readByReadAtRepoStub{}
// userRepo is nil → the default path would attempt ListWithFilters and crash.
// We only care that the read-at path is NOT taken; assert via readRepo.called.
defer func() {
_ = recover() // swallow the nil-pointer panic from the default path
require.False(t, readRepo.called, "expected default path when sort_by != read_at")
}()
svc := NewAnnouncementService(annRepo, readRepo, nil, nil)

_, _, _ = svc.ListUserReadStatus(context.Background(), 1, pagination.PaginationParams{
Page: 1, PageSize: 20, SortBy: "email", SortOrder: "asc",
}, "")
}

func TestListUserReadStatus_TrimsAndLowercasesSortBy(t *testing.T) {
t.Parallel()

annRepo := &announcementRepoStub{item: &Announcement{ID: 1, Title: "x", Content: "x"}}
readRepo := &readByReadAtRepoStub{
page: &pagination.PaginationResult{Page: 1, PageSize: 20, Total: 0, Pages: 0},
}
svc := NewAnnouncementService(annRepo, readRepo, nil, userSubRepoNoSubs{})

// " READ_AT " should still route to the read-at path.
_, _, err := svc.ListUserReadStatus(context.Background(), 1, pagination.PaginationParams{
Page: 1, PageSize: 20, SortBy: " READ_AT ", SortOrder: "desc",
}, "")
require.NoError(t, err)
require.True(t, readRepo.called)
}

func timePtr(t time.Time) *time.Time { return &t }
49 changes: 49 additions & 0 deletions backend/internal/service/announcement_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,10 @@ func (s *AnnouncementService) ListUserReadStatus(
return nil, nil, err
}

if strings.ToLower(strings.TrimSpace(params.SortBy)) == "read_at" {
return s.listUserReadStatusByReadAt(ctx, ann, announcementID, params, search)
}

filters := UserListFilters{
Search: strings.TrimSpace(search),
}
Expand Down Expand Up @@ -387,6 +391,51 @@ func (s *AnnouncementService) ListUserReadStatus(
return out, page, nil
}

// listUserReadStatusByReadAt 走 announcement_reads + users 的 LEFT JOIN 路径,
// 让排序由 SQL 完成。未读用户在 desc 时排末尾、asc 时排开头(便于在同一页面里
// 既能"看最近阅读人"也能"看尚未阅读的人")。
//
// listUserReadStatusByReadAt uses a LEFT JOIN-driven SQL ordering so the
// pagination window honors read_at. Unread users sort to the end for descending
// order and to the start for ascending order.
func (s *AnnouncementService) listUserReadStatusByReadAt(
ctx context.Context,
ann *Announcement,
announcementID int64,
params pagination.PaginationParams,
search string,
) ([]AnnouncementUserReadStatus, *pagination.PaginationResult, error) {
rows, pageResult, err := s.readRepo.ListUsersOrderedByReadAt(ctx, announcementID, params, search)
if err != nil {
return nil, nil, fmt.Errorf("list users ordered by read_at: %w", err)
}

out := make([]AnnouncementUserReadStatus, 0, len(rows))
for i := range rows {
row := rows[i]

subs, err := s.userSubRepo.ListActiveByUserID(ctx, row.UserID)
if err != nil {
return nil, nil, fmt.Errorf("list active subscriptions: %w", err)
}
activeGroupIDs := make(map[int64]struct{}, len(subs))
for j := range subs {
activeGroupIDs[subs[j].GroupID] = struct{}{}
}

out = append(out, AnnouncementUserReadStatus{
UserID: row.UserID,
Email: row.Email,
Username: row.Username,
Balance: row.Balance,
Eligible: domain.AnnouncementTargeting(ann.Targeting).Matches(row.Balance, activeGroupIDs),
ReadAt: row.ReadAt,
})
}

return out, pageResult, nil
}

func isValidAnnouncementStatus(status string) bool {
switch status {
case AnnouncementStatusDraft, AnnouncementStatusActive, AnnouncementStatusArchived:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- 加速 "公告已读情况" 后台页面按已读时间排序的查询。
-- Speeds up the admin "announcement read status" page when sorting by read_at.
--
-- 现有索引 idx_announcement_reads_read_at 是全局单列索引,不适合
-- "某个公告内按 read_at 排序"的查询模式 —— 优化器会回退到先按 announcement_id
-- 过滤再内存排序。本索引把 announcement_id 作为前导列,使排序能直接走索引扫描。
--
-- The existing idx_announcement_reads_read_at is a global single-column index
-- that does not help the "within a single announcement, order by read_at"
-- query pattern. This composite index lets that ordering be served directly
-- from index scans.

CREATE INDEX IF NOT EXISTS idx_announcement_reads_announcement_id_read_at
ON announcement_reads (announcement_id, read_at DESC NULLS LAST);
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const columns = computed<Column[]>(() => [
{ key: 'username', label: t('admin.users.columns.username'), sortable: true },
{ key: 'balance', label: t('common.balance'), sortable: true },
{ key: 'eligible', label: t('admin.announcements.eligible') },
{ key: 'read_at', label: t('admin.announcements.readAt') }
{ key: 'read_at', label: t('admin.announcements.readAt'), sortable: true }
])

let currentController: AbortController | null = null
Expand Down
Loading