2502 lines
81 KiB
Go
2502 lines
81 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"reflect"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/postgres/sqlcgen"
|
||
)
|
||
|
||
// StoryStore persists Telegram story snapshots and per-viewer state in PG.
|
||
type StoryStore struct {
|
||
db sqlcgen.DBTX
|
||
}
|
||
|
||
// NewStoryStore creates a PostgreSQL-backed story store.
|
||
func NewStoryStore(db sqlcgen.DBTX) *StoryStore {
|
||
return &StoryStore{db: db}
|
||
}
|
||
|
||
const storySelectColumns = `
|
||
s.owner_peer_type,
|
||
s.owner_peer_id,
|
||
s.story_id,
|
||
s.random_id,
|
||
s.date,
|
||
s.expire_date,
|
||
s.deleted,
|
||
s.pinned,
|
||
s.pinned_to_top_order,
|
||
s.public,
|
||
s.close_friends,
|
||
s.contacts,
|
||
s.selected_contacts,
|
||
s.noforwards,
|
||
s.edited,
|
||
COALESCE(s.privacy_rules::text, '[]')::text,
|
||
s.allow_user_ids,
|
||
s.disallow_user_ids,
|
||
s.caption,
|
||
COALESCE(s.entities::text, '[]')::text,
|
||
COALESCE(s.media::text, '{}')::text,
|
||
COALESCE(s.media_areas::text, '[]')::text,
|
||
COALESCE(s.fwd_from::text, '{}')::text`
|
||
|
||
const storyReturningColumns = `
|
||
owner_peer_type,
|
||
owner_peer_id,
|
||
story_id,
|
||
random_id,
|
||
date,
|
||
expire_date,
|
||
deleted,
|
||
pinned,
|
||
pinned_to_top_order,
|
||
public,
|
||
close_friends,
|
||
contacts,
|
||
selected_contacts,
|
||
noforwards,
|
||
edited,
|
||
COALESCE(privacy_rules::text, '[]')::text,
|
||
allow_user_ids,
|
||
disallow_user_ids,
|
||
caption,
|
||
COALESCE(entities::text, '[]')::text,
|
||
COALESCE(media::text, '{}')::text,
|
||
COALESCE(media_areas::text, '[]')::text,
|
||
COALESCE(fwd_from::text, '{}')::text`
|
||
|
||
func storyVisiblePredicate(viewerParam string) string {
|
||
return storyVisiblePredicateFor("s", viewerParam)
|
||
}
|
||
|
||
func storyPublicRepostVisiblePredicateFor(alias, viewerParam string) string {
|
||
return storyBaseVisiblePredicateFor(alias, viewerParam)
|
||
}
|
||
|
||
// storyForwardSourceTypeSQL / storyForwardSourceIDSQL 复刻 memory store 的 repost 源解析
|
||
// (memory story.go effectiveForwardSource:forward.Source.ID != 0 用 Source,否则回退 From)。
|
||
// 原 postgres 写法只在 Source 字段为空串时回退 From,但零值 Peer 的 Source.ID 会序列化成 "0"
|
||
// (非空串),使「只设了 From 的 repost」被计成 source_id=0 → forwards_count/列表错位为 0,
|
||
// 与 memory 行为漂移。两 store 必须一致:按 Source.ID 是否为 0 在 Source / From 间整体切换。
|
||
func storyForwardSourceTypeSQL(alias string) string {
|
||
return `(CASE WHEN COALESCE(NULLIF(` + alias + `.fwd_from->'Source'->>'ID', ''), '0')::bigint <> 0
|
||
THEN ` + alias + `.fwd_from->'Source'->>'Type'
|
||
ELSE ` + alias + `.fwd_from->'From'->>'Type' END)`
|
||
}
|
||
|
||
func storyForwardSourceIDSQL(alias string) string {
|
||
return `(CASE WHEN COALESCE(NULLIF(` + alias + `.fwd_from->'Source'->>'ID', ''), '0')::bigint <> 0
|
||
THEN (` + alias + `.fwd_from->'Source'->>'ID')::bigint
|
||
ELSE (` + alias + `.fwd_from->'From'->>'ID')::bigint END)`
|
||
}
|
||
|
||
func storyVisiblePredicateFor(alias, viewerParam string) string {
|
||
return `(
|
||
(` + alias + `.owner_peer_type <> 'channel'
|
||
OR EXISTS (
|
||
SELECT 1
|
||
FROM channel_members cm
|
||
WHERE cm.channel_id = ` + alias + `.owner_peer_id
|
||
AND cm.user_id = ` + viewerParam + `
|
||
AND cm.status = 'active'
|
||
AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false)
|
||
)
|
||
)
|
||
AND ` + storyBaseVisiblePredicateFor(alias, viewerParam) + `
|
||
)`
|
||
}
|
||
|
||
func storyBaseVisiblePredicateFor(alias, viewerParam string) string {
|
||
return `(
|
||
(` + alias + `.owner_peer_type = 'user' AND ` + alias + `.owner_peer_id = ` + viewerParam + `)
|
||
OR (
|
||
NOT (
|
||
` + alias + `.owner_peer_type = 'user'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM contact_blocks b
|
||
WHERE b.owner_user_id = ` + alias + `.owner_peer_id
|
||
AND b.blocked_user_id = ` + viewerParam + `
|
||
)
|
||
)
|
||
AND
|
||
NOT (` + viewerParam + ` = ANY(` + alias + `.disallow_user_ids))
|
||
AND (
|
||
` + alias + `.public
|
||
OR ` + viewerParam + ` = ANY(` + alias + `.allow_user_ids)
|
||
OR (
|
||
` + alias + `.owner_peer_type = 'user'
|
||
AND ` + alias + `.contacts
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM contacts c
|
||
WHERE c.user_id = ` + alias + `.owner_peer_id
|
||
AND c.contact_user_id = ` + viewerParam + `
|
||
)
|
||
)
|
||
OR (
|
||
` + alias + `.owner_peer_type = 'user'
|
||
AND ` + alias + `.close_friends
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM contacts c
|
||
WHERE c.user_id = ` + alias + `.owner_peer_id
|
||
AND c.contact_user_id = ` + viewerParam + `
|
||
AND c.close_friend
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)`
|
||
}
|
||
|
||
func (s *StoryStore) CreateStory(ctx context.Context, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) {
|
||
if err := validatePGStoryPeer(req.Owner); err != nil {
|
||
return domain.StoryCreateResult{}, err
|
||
}
|
||
if req.RandomID == 0 {
|
||
return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid
|
||
}
|
||
if existing, ok, err := s.storyByRandomID(ctx, req.Owner, req.RandomID); err != nil {
|
||
return domain.StoryCreateResult{}, err
|
||
} else if ok {
|
||
return domain.StoryCreateResult{Story: existing, Duplicate: true}, nil
|
||
}
|
||
entities, err := encodeMessageEntities(req.Entities)
|
||
if err != nil {
|
||
return domain.StoryCreateResult{}, fmt.Errorf("encode story entities: %w", err)
|
||
}
|
||
media, err := encodeMessageMedia(req.Media)
|
||
if err != nil {
|
||
return domain.StoryCreateResult{}, fmt.Errorf("encode story media: %w", err)
|
||
}
|
||
mediaAreas, err := encodeStoryMediaAreas(req.MediaAreas)
|
||
if err != nil {
|
||
return domain.StoryCreateResult{}, fmt.Errorf("encode story media areas: %w", err)
|
||
}
|
||
forward, err := encodeStoryForward(req.Forward)
|
||
if err != nil {
|
||
return domain.StoryCreateResult{}, fmt.Errorf("encode story forward: %w", err)
|
||
}
|
||
privacyRules, err := encodePrivacyRules(req.PrivacyRules)
|
||
if err != nil {
|
||
return domain.StoryCreateResult{}, fmt.Errorf("encode story privacy rules: %w", err)
|
||
}
|
||
for attempt := 0; attempt < 5; attempt++ {
|
||
row := s.db.QueryRow(ctx, `
|
||
WITH next_id AS (
|
||
SELECT COALESCE(MAX(story_id), 0) + 1 AS story_id
|
||
FROM stories
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
),
|
||
inserted AS (
|
||
INSERT INTO stories (
|
||
owner_peer_type, owner_peer_id, story_id, random_id, date, expire_date,
|
||
deleted, pinned, pinned_to_top_order, public, close_friends, contacts, selected_contacts,
|
||
noforwards, edited, privacy_rules, allow_user_ids, disallow_user_ids, caption, entities, media, media_areas, fwd_from
|
||
)
|
||
SELECT
|
||
$1, $2, next_id.story_id, $3, $4, $5,
|
||
false, $6, 0, $7, $8, $9, $10,
|
||
$11, false, $12::jsonb, $13::bigint[], $14::bigint[], $15, $16::jsonb, $17::jsonb, $18::jsonb, $19::jsonb
|
||
FROM next_id
|
||
WHERE next_id.story_id <= $20
|
||
RETURNING `+storyReturningColumns+`
|
||
),
|
||
self_read AS (
|
||
INSERT INTO story_read_states (viewer_user_id, owner_peer_type, owner_peer_id, max_read_id, date)
|
||
SELECT owner_peer_id, owner_peer_type, owner_peer_id, story_id, date
|
||
FROM inserted
|
||
WHERE owner_peer_type = 'user'
|
||
ON CONFLICT (viewer_user_id, owner_peer_type, owner_peer_id) DO UPDATE SET
|
||
max_read_id = GREATEST(story_read_states.max_read_id, EXCLUDED.max_read_id),
|
||
date = CASE
|
||
WHEN EXCLUDED.max_read_id > story_read_states.max_read_id THEN EXCLUDED.date
|
||
ELSE story_read_states.date
|
||
END,
|
||
updated_at = CASE
|
||
WHEN EXCLUDED.max_read_id > story_read_states.max_read_id THEN now()
|
||
ELSE story_read_states.updated_at
|
||
END
|
||
RETURNING 1
|
||
)
|
||
-- inserted 的列已是 storyReturningColumns(含 COALESCE 等表达式求值后的结果),外层不能再按
|
||
-- 原列名(如 privacy_rules)重复套表达式——那些列在 CTE 里是表达式结果而非原始列,会报
|
||
-- "column does not exist"。直接 SELECT * 取 CTE 行(顺序与 storyReturningColumns 一致,按位扫描)。
|
||
SELECT * FROM inserted`,
|
||
string(req.Owner.Type), req.Owner.ID, req.RandomID, int32(req.Date), int32(req.Date+req.Period),
|
||
req.Pinned, req.Public, req.CloseFriends, req.Contacts, req.SelectedContacts,
|
||
req.NoForwards, privacyRules, nonNullInt64s(req.AllowUserIDs), nonNullInt64s(req.DisallowUserIDs), req.Caption, entities, media, mediaAreas, forward, int32(domain.MaxStoryID))
|
||
story, err := scanPGStory(row, req.Owner.ID)
|
||
if err == nil {
|
||
return domain.StoryCreateResult{Story: story}, nil
|
||
}
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid
|
||
}
|
||
var pgErr *pgconn.PgError
|
||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||
if existing, ok, loadErr := s.storyByRandomID(ctx, req.Owner, req.RandomID); loadErr != nil {
|
||
return domain.StoryCreateResult{}, loadErr
|
||
} else if ok {
|
||
return domain.StoryCreateResult{Story: existing, Duplicate: true}, nil
|
||
}
|
||
continue
|
||
}
|
||
return domain.StoryCreateResult{}, fmt.Errorf("create story: %w", err)
|
||
}
|
||
return domain.StoryCreateResult{}, fmt.Errorf("create story: exhausted id allocation retries")
|
||
}
|
||
|
||
func (s *StoryStore) UpsertStory(ctx context.Context, req domain.UpsertStoryRequest) (domain.Story, error) {
|
||
story := clonePGStory(req.Story)
|
||
if err := validatePGStoryIdentity(story.Owner, story.ID); err != nil {
|
||
return domain.Story{}, err
|
||
}
|
||
if story.Deleted || !story.Pinned {
|
||
story.PinnedToTopOrder = 0
|
||
}
|
||
entities, err := encodeMessageEntities(story.Entities)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("encode story entities: %w", err)
|
||
}
|
||
media, err := encodeMessageMedia(story.Media)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("encode story media: %w", err)
|
||
}
|
||
mediaAreas, err := encodeStoryMediaAreas(story.MediaAreas)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("encode story media areas: %w", err)
|
||
}
|
||
forward, err := encodeStoryForward(story.Forward)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("encode story forward: %w", err)
|
||
}
|
||
privacyRules, err := encodePrivacyRules(story.PrivacyRules)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("encode story privacy rules: %w", err)
|
||
}
|
||
row := s.db.QueryRow(ctx, `
|
||
INSERT INTO stories (
|
||
owner_peer_type, owner_peer_id, story_id, random_id, date, expire_date,
|
||
deleted, pinned, pinned_to_top_order, public, close_friends, contacts, selected_contacts,
|
||
noforwards, edited, privacy_rules, allow_user_ids, disallow_user_ids, caption, entities, media, media_areas, fwd_from
|
||
) VALUES (
|
||
$1, $2, $3, $4, $5, $6,
|
||
$7, $8, $9, $10, $11, $12, $13,
|
||
$14, $15, $16::jsonb, $17::bigint[], $18::bigint[], $19, $20::jsonb, $21::jsonb, $22::jsonb, $23::jsonb
|
||
)
|
||
ON CONFLICT (owner_peer_type, owner_peer_id, story_id) DO UPDATE SET
|
||
random_id = EXCLUDED.random_id,
|
||
date = EXCLUDED.date,
|
||
expire_date = EXCLUDED.expire_date,
|
||
deleted = EXCLUDED.deleted,
|
||
pinned = EXCLUDED.pinned,
|
||
pinned_to_top_order = EXCLUDED.pinned_to_top_order,
|
||
public = EXCLUDED.public,
|
||
close_friends = EXCLUDED.close_friends,
|
||
contacts = EXCLUDED.contacts,
|
||
selected_contacts = EXCLUDED.selected_contacts,
|
||
noforwards = EXCLUDED.noforwards,
|
||
edited = EXCLUDED.edited,
|
||
privacy_rules = EXCLUDED.privacy_rules,
|
||
allow_user_ids = EXCLUDED.allow_user_ids,
|
||
disallow_user_ids = EXCLUDED.disallow_user_ids,
|
||
caption = EXCLUDED.caption,
|
||
entities = EXCLUDED.entities,
|
||
media = EXCLUDED.media,
|
||
media_areas = EXCLUDED.media_areas,
|
||
fwd_from = EXCLUDED.fwd_from,
|
||
updated_at = now()
|
||
RETURNING `+storyReturningColumns,
|
||
string(story.Owner.Type), story.Owner.ID, int32(story.ID), story.RandomID, int32(story.Date), int32(story.ExpireDate),
|
||
story.Deleted, story.Pinned, story.PinnedToTopOrder, story.Public, story.CloseFriends, story.Contacts, story.SelectedContacts,
|
||
story.NoForwards, story.Edited, privacyRules, nonNullInt64s(story.AllowUserIDs), nonNullInt64s(story.DisallowUserIDs), story.Caption, entities, media, mediaAreas, forward)
|
||
out, err := scanPGStory(row, 0)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("upsert story: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *StoryStore) ListActiveStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) {
|
||
return s.ListActiveStoriesPage(ctx, viewerUserID, hidden, now, domain.StoryListCursor{}, limit)
|
||
}
|
||
|
||
func (s *StoryStore) ListActiveStoriesPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) {
|
||
if viewerUserID == 0 {
|
||
return domain.StoryList{}, nil
|
||
}
|
||
limit = clampPGStoryLimit(limit)
|
||
total, err := s.countActiveStoryPeers(ctx, viewerUserID, hidden, now)
|
||
if err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
owners, err := s.listActiveStoryPeerPage(ctx, viewerUserID, hidden, now, cursor, limit+1)
|
||
if err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
hasMore := len(owners) > limit
|
||
if hasMore {
|
||
owners = owners[:limit]
|
||
}
|
||
if len(owners) == 0 {
|
||
return domain.StoryList{Count: total}, nil
|
||
}
|
||
stories, err := s.listActiveStoriesForPeers(ctx, viewerUserID, now, owners)
|
||
if err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
reads, err := s.ListReadStates(ctx, viewerUserID)
|
||
if err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
peers := groupPGPeerStories(stories, reads)
|
||
return domain.StoryList{
|
||
Count: total,
|
||
HasMore: hasMore,
|
||
Stories: stories,
|
||
Peers: peers,
|
||
}, nil
|
||
}
|
||
|
||
func (s *StoryStore) ActiveStoriesDigest(ctx context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) {
|
||
if viewerUserID == 0 {
|
||
return domain.StoryListDigest{}, nil
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
LEFT JOIN story_hidden_peers h
|
||
ON h.viewer_user_id = $1
|
||
AND h.owner_peer_type = s.owner_peer_type
|
||
AND h.owner_peer_id = s.owner_peer_id
|
||
WHERE s.deleted = false
|
||
AND s.expire_date > $2
|
||
AND `+storyVisiblePredicate("$1")+`
|
||
AND (($3::boolean AND h.viewer_user_id IS NOT NULL) OR (NOT $3::boolean AND h.viewer_user_id IS NULL))
|
||
ORDER BY s.date DESC, s.owner_peer_type ASC, s.owner_peer_id ASC, s.story_id DESC`, viewerUserID, int32(now), hidden)
|
||
if err != nil {
|
||
return domain.StoryListDigest{}, fmt.Errorf("digest active stories: %w", err)
|
||
}
|
||
stories, err := scanPGStories(rows, viewerUserID)
|
||
if err != nil {
|
||
return domain.StoryListDigest{}, err
|
||
}
|
||
if len(stories) == 0 {
|
||
return domain.StoryListDigest{}, nil
|
||
}
|
||
if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryListDigest{}, err
|
||
}
|
||
reads, err := s.ListReadStates(ctx, viewerUserID)
|
||
if err != nil {
|
||
return domain.StoryListDigest{}, err
|
||
}
|
||
return domain.DigestStoryPeerList(groupPGPeerStories(stories, reads)), nil
|
||
}
|
||
|
||
type activeStoryPeerOwner struct {
|
||
peer domain.Peer
|
||
maxDate int
|
||
}
|
||
|
||
func (s *StoryStore) countActiveStoryPeers(ctx context.Context, viewerUserID int64, hidden bool, now int) (int, error) {
|
||
var count int
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM (
|
||
SELECT s.owner_peer_type, s.owner_peer_id
|
||
FROM stories s
|
||
LEFT JOIN story_hidden_peers h
|
||
ON h.viewer_user_id = $1
|
||
AND h.owner_peer_type = s.owner_peer_type
|
||
AND h.owner_peer_id = s.owner_peer_id
|
||
WHERE s.deleted = false
|
||
AND s.expire_date > $2
|
||
AND `+storyVisiblePredicate("$1")+`
|
||
AND (($3::boolean AND h.viewer_user_id IS NOT NULL) OR (NOT $3::boolean AND h.viewer_user_id IS NULL))
|
||
GROUP BY s.owner_peer_type, s.owner_peer_id
|
||
) peers`, viewerUserID, int32(now), hidden).Scan(&count); err != nil {
|
||
return 0, fmt.Errorf("count active story peers: %w", err)
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
func (s *StoryStore) listActiveStoryPeerPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) ([]activeStoryPeerOwner, error) {
|
||
limit = clampPGStoryProbeLimit(limit)
|
||
args := []any{viewerUserID, int32(now), hidden}
|
||
having := ""
|
||
if cursor.Set {
|
||
args = append(args, int32(cursor.Date), string(cursor.Peer.Type), cursor.Peer.ID)
|
||
having = `
|
||
HAVING MAX(s.date) < $4
|
||
OR (MAX(s.date) = $4 AND (s.owner_peer_type > $5 OR (s.owner_peer_type = $5 AND s.owner_peer_id > $6)))`
|
||
}
|
||
args = append(args, int32(limit))
|
||
limitParam := len(args)
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT s.owner_peer_type, s.owner_peer_id, MAX(s.date)::int AS max_date
|
||
FROM stories s
|
||
LEFT JOIN story_hidden_peers h
|
||
ON h.viewer_user_id = $1
|
||
AND h.owner_peer_type = s.owner_peer_type
|
||
AND h.owner_peer_id = s.owner_peer_id
|
||
WHERE s.deleted = false
|
||
AND s.expire_date > $2
|
||
AND `+storyVisiblePredicate("$1")+`
|
||
AND (($3::boolean AND h.viewer_user_id IS NOT NULL) OR (NOT $3::boolean AND h.viewer_user_id IS NULL))
|
||
GROUP BY s.owner_peer_type, s.owner_peer_id`+having+`
|
||
ORDER BY max_date DESC, s.owner_peer_type ASC, s.owner_peer_id ASC
|
||
LIMIT $`+fmt.Sprint(limitParam), args...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list active story peer page: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
owners := make([]activeStoryPeerOwner, 0, limit)
|
||
for rows.Next() {
|
||
var peerType string
|
||
var owner activeStoryPeerOwner
|
||
if err := rows.Scan(&peerType, &owner.peer.ID, &owner.maxDate); err != nil {
|
||
return nil, fmt.Errorf("scan active story peer page: %w", err)
|
||
}
|
||
owner.peer.Type = domain.PeerType(peerType)
|
||
owners = append(owners, owner)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("iterate active story peer page: %w", err)
|
||
}
|
||
return owners, nil
|
||
}
|
||
|
||
func (s *StoryStore) listActiveStoriesForPeers(ctx context.Context, viewerUserID int64, now int, owners []activeStoryPeerOwner) ([]domain.Story, error) {
|
||
args := []any{viewerUserID, int32(now)}
|
||
clauses := make([]string, 0, len(owners))
|
||
for _, owner := range owners {
|
||
args = append(args, string(owner.peer.Type), owner.peer.ID)
|
||
clauses = append(clauses, fmt.Sprintf("(s.owner_peer_type = $%d AND s.owner_peer_id = $%d)", len(args)-1, len(args)))
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.deleted = false
|
||
AND s.expire_date > $2
|
||
AND `+storyVisiblePredicate("$1")+`
|
||
AND (`+strings.Join(clauses, " OR ")+`)
|
||
ORDER BY s.date DESC, s.owner_peer_type ASC, s.owner_peer_id ASC, s.story_id DESC`, args...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list active stories for peers: %w", err)
|
||
}
|
||
stories, err := scanPGStories(rows, viewerUserID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return stories, nil
|
||
}
|
||
|
||
func (s *StoryStore) ListOwnerActiveStories(ctx context.Context, owner domain.Peer, now, limit int) (domain.StoryList, error) {
|
||
if err := validatePGStoryPeer(owner); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
limit = clampPGStoryLimit(limit)
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.deleted = false
|
||
AND s.expire_date > $3
|
||
ORDER BY s.story_id ASC
|
||
LIMIT $4`, string(owner.Type), owner.ID, int32(now), int32(limit))
|
||
if err != nil {
|
||
return domain.StoryList{}, fmt.Errorf("list owner active stories: %w", err)
|
||
}
|
||
stories, err := scanPGStories(rows, 0)
|
||
if err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
for i := range stories {
|
||
stories[i] = fanoutPGStorySnapshot(stories[i])
|
||
}
|
||
return domain.StoryList{Count: len(stories), Stories: stories}, nil
|
||
}
|
||
|
||
func (s *StoryStore) GetPeerStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) {
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return domain.PeerStories{}, err
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.deleted = false
|
||
AND s.expire_date > $3
|
||
AND `+storyVisiblePredicate("$4")+`
|
||
ORDER BY s.story_id ASC`, string(peer.Type), peer.ID, int32(now), viewerUserID)
|
||
if err != nil {
|
||
return domain.PeerStories{}, fmt.Errorf("get peer stories: %w", err)
|
||
}
|
||
stories, err := scanPGStories(rows, viewerUserID)
|
||
if err != nil {
|
||
return domain.PeerStories{}, err
|
||
}
|
||
if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil {
|
||
return domain.PeerStories{}, err
|
||
}
|
||
if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil {
|
||
return domain.PeerStories{}, err
|
||
}
|
||
read, err := s.getReadState(ctx, viewerUserID, peer)
|
||
if err != nil {
|
||
return domain.PeerStories{}, err
|
||
}
|
||
return domain.PeerStories{Peer: peer, MaxReadID: read.MaxReadID, Stories: stories}, nil
|
||
}
|
||
|
||
func (s *StoryStore) GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) {
|
||
_ = now
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
ids, err := normalizePGStoryIDsNonEmpty(ids)
|
||
if err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.story_id = ANY($3::int[])
|
||
AND s.deleted = false
|
||
AND `+storyVisiblePredicate("$4")+`
|
||
ORDER BY array_position($3::int[], s.story_id)`, string(peer.Type), peer.ID, int32s(ids), viewerUserID)
|
||
if err != nil {
|
||
return domain.StoryList{}, fmt.Errorf("get stories by id: %w", err)
|
||
}
|
||
stories, err := scanPGStories(rows, viewerUserID)
|
||
if err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
return domain.StoryList{Count: len(stories), Stories: stories}, nil
|
||
}
|
||
|
||
func (s *StoryStore) ListPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
|
||
_ = now
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if offsetID < 0 {
|
||
offsetID = 0
|
||
}
|
||
limit = clampPGStoryLimit(limit)
|
||
var count int
|
||
var pinnedToTop32 []int32
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT COUNT(*)::int,
|
||
COALESCE(
|
||
array_agg(s.story_id ORDER BY s.pinned_to_top_order ASC, s.story_id DESC)
|
||
FILTER (WHERE s.pinned_to_top_order > 0),
|
||
'{}'::int[]
|
||
)
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.deleted = false
|
||
AND s.pinned = true
|
||
AND `+storyVisiblePredicate("$3"), string(peer.Type), peer.ID, viewerUserID).Scan(&count, &pinnedToTop32); err != nil {
|
||
return domain.StoryList{}, fmt.Errorf("summarize pinned stories: %w", err)
|
||
}
|
||
pinnedToTop := make([]int, 0, len(pinnedToTop32))
|
||
for _, id := range pinnedToTop32 {
|
||
pinnedToTop = append(pinnedToTop, int(id))
|
||
}
|
||
if count == 0 {
|
||
return domain.StoryList{Count: 0, PinnedToTop: pinnedToTop}, nil
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.deleted = false
|
||
AND s.pinned = true
|
||
AND ($3::int = 0 OR s.story_id < $3)
|
||
AND `+storyVisiblePredicate("$5")+`
|
||
ORDER BY s.story_id DESC
|
||
LIMIT $4`, string(peer.Type), peer.ID, int32(offsetID), int32(limit), viewerUserID)
|
||
if err != nil {
|
||
return domain.StoryList{}, fmt.Errorf("list pinned stories: %w", err)
|
||
}
|
||
stories, err := scanPGStories(rows, viewerUserID)
|
||
if err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
return domain.StoryList{Count: count, Stories: stories, PinnedToTop: pinnedToTop}, nil
|
||
}
|
||
|
||
func (s *StoryStore) HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) {
|
||
_ = now
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return false, err
|
||
}
|
||
var exists bool
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.deleted = false
|
||
AND s.pinned = true
|
||
AND `+storyVisiblePredicate("$3")+`
|
||
)`, string(peer.Type), peer.ID, viewerUserID).Scan(&exists); err != nil {
|
||
return false, fmt.Errorf("has pinned stories: %w", err)
|
||
}
|
||
return exists, nil
|
||
}
|
||
|
||
func (s *StoryStore) ListStoriesArchive(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if offsetID < 0 {
|
||
offsetID = 0
|
||
}
|
||
var count int
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT count(*)::int
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.deleted = false
|
||
AND s.expire_date <= $3`, string(peer.Type), peer.ID, int32(now)).Scan(&count); err != nil {
|
||
return domain.StoryList{}, fmt.Errorf("count story archive: %w", err)
|
||
}
|
||
if limit == 0 {
|
||
return domain.StoryList{Count: count}, nil
|
||
}
|
||
limit = clampPGStoryLimit(limit)
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.deleted = false
|
||
AND s.expire_date <= $3
|
||
AND ($4::int = 0 OR s.story_id < $4)
|
||
ORDER BY s.story_id DESC
|
||
LIMIT $5`, string(peer.Type), peer.ID, int32(now), int32(offsetID), int32(limit))
|
||
if err != nil {
|
||
return domain.StoryList{}, fmt.Errorf("list story archive: %w", err)
|
||
}
|
||
stories, err := scanPGStories(rows, viewerUserID)
|
||
if err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryList{}, err
|
||
}
|
||
return domain.StoryList{Count: count, Stories: stories}, nil
|
||
}
|
||
|
||
func (s *StoryStore) ListReadStates(ctx context.Context, viewerUserID int64) ([]domain.StoryReadState, error) {
|
||
if viewerUserID == 0 {
|
||
return nil, nil
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT viewer_user_id, owner_peer_type, owner_peer_id, max_read_id, date
|
||
FROM story_read_states
|
||
WHERE viewer_user_id = $1
|
||
ORDER BY owner_peer_type ASC, owner_peer_id ASC`, viewerUserID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list story read states: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make([]domain.StoryReadState, 0)
|
||
for rows.Next() {
|
||
var state domain.StoryReadState
|
||
var peerType string
|
||
if err := rows.Scan(&state.ViewerID, &peerType, &state.Peer.ID, &state.MaxReadID, &state.Date); err != nil {
|
||
return nil, fmt.Errorf("scan story read state: %w", err)
|
||
}
|
||
state.Peer.Type = domain.PeerType(peerType)
|
||
out = append(out, state)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("scan story read states: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *StoryStore) GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) {
|
||
if len(peers) > domain.MaxStoryIDs {
|
||
return nil, domain.ErrStoryIDInvalid
|
||
}
|
||
if len(peers) == 0 {
|
||
return nil, nil
|
||
}
|
||
peerTypes := make([]string, 0, len(peers))
|
||
peerIDs := make([]int64, 0, len(peers))
|
||
for _, peer := range peers {
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return nil, err
|
||
}
|
||
peerTypes = append(peerTypes, string(peer.Type))
|
||
peerIDs = append(peerIDs, peer.ID)
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
WITH input AS (
|
||
SELECT p.peer_type, i.peer_id, p.ord
|
||
FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord)
|
||
JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord)
|
||
)
|
||
SELECT input.peer_type, input.peer_id, COALESCE(MAX(s.story_id), 0)::int
|
||
FROM input
|
||
LEFT JOIN stories s
|
||
ON s.owner_peer_type = input.peer_type
|
||
AND s.owner_peer_id = input.peer_id
|
||
AND s.deleted = false
|
||
AND s.expire_date > $3
|
||
AND `+storyVisiblePredicate("$4")+`
|
||
GROUP BY input.ord, input.peer_type, input.peer_id
|
||
ORDER BY input.ord ASC`, peerTypes, peerIDs, int32(now), viewerUserID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get story peer max ids: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make([]domain.RecentStory, 0, len(peers))
|
||
for rows.Next() {
|
||
var peerType string
|
||
var item domain.RecentStory
|
||
if err := rows.Scan(&peerType, &item.Peer.ID, &item.MaxID); err != nil {
|
||
return nil, fmt.Errorf("scan story peer max id: %w", err)
|
||
}
|
||
item.Peer.Type = domain.PeerType(peerType)
|
||
out = append(out, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("scan story peer max ids: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *StoryStore) GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) {
|
||
if len(peers) > domain.MaxStoryIDs {
|
||
return nil, domain.ErrStoryIDInvalid
|
||
}
|
||
if viewerUserID == 0 {
|
||
return nil, domain.ErrStoryPeerInvalid
|
||
}
|
||
if len(peers) == 0 {
|
||
return map[domain.Peer]bool{}, nil
|
||
}
|
||
peerTypes := make([]string, 0, len(peers))
|
||
peerIDs := make([]int64, 0, len(peers))
|
||
for _, peer := range peers {
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return nil, err
|
||
}
|
||
peerTypes = append(peerTypes, string(peer.Type))
|
||
peerIDs = append(peerIDs, peer.ID)
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
WITH input AS (
|
||
SELECT p.peer_type, i.peer_id, p.ord
|
||
FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord)
|
||
JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord)
|
||
)
|
||
SELECT input.peer_type, input.peer_id, (h.viewer_user_id IS NOT NULL) AS hidden
|
||
FROM input
|
||
LEFT JOIN story_hidden_peers h
|
||
ON h.viewer_user_id = $3
|
||
AND h.owner_peer_type = input.peer_type
|
||
AND h.owner_peer_id = input.peer_id
|
||
ORDER BY input.ord ASC`, peerTypes, peerIDs, viewerUserID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get story peer hidden states: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make(map[domain.Peer]bool, len(peers))
|
||
for rows.Next() {
|
||
var peerType string
|
||
var peer domain.Peer
|
||
var hidden bool
|
||
if err := rows.Scan(&peerType, &peer.ID, &hidden); err != nil {
|
||
return nil, fmt.Errorf("scan story peer hidden state: %w", err)
|
||
}
|
||
peer.Type = domain.PeerType(peerType)
|
||
out[peer] = hidden
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("scan story peer hidden states: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *StoryStore) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) {
|
||
if len(peers) > domain.MaxStoryIDs {
|
||
return nil, domain.ErrStoryIDInvalid
|
||
}
|
||
if viewerUserID == 0 {
|
||
return nil, domain.ErrStoryPeerInvalid
|
||
}
|
||
if len(peers) == 0 {
|
||
return nil, nil
|
||
}
|
||
peerTypes := make([]string, 0, len(peers))
|
||
peerIDs := make([]int64, 0, len(peers))
|
||
for _, peer := range peers {
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return nil, err
|
||
}
|
||
peerTypes = append(peerTypes, string(peer.Type))
|
||
peerIDs = append(peerIDs, peer.ID)
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
WITH input AS (
|
||
SELECT p.peer_type, i.peer_id, p.ord
|
||
FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord)
|
||
JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord)
|
||
),
|
||
recent AS (
|
||
SELECT input.peer_type, input.peer_id, input.ord, COALESCE(MAX(s.story_id), 0)::int AS max_story_id
|
||
FROM input
|
||
LEFT JOIN stories s
|
||
ON s.owner_peer_type = input.peer_type
|
||
AND s.owner_peer_id = input.peer_id
|
||
AND s.deleted = false
|
||
AND s.expire_date > $3
|
||
AND `+storyVisiblePredicate("$4")+`
|
||
GROUP BY input.ord, input.peer_type, input.peer_id
|
||
)
|
||
SELECT recent.peer_type, recent.peer_id, recent.max_story_id, (h.viewer_user_id IS NOT NULL) AS hidden
|
||
FROM recent
|
||
LEFT JOIN story_hidden_peers h
|
||
ON h.viewer_user_id = $4
|
||
AND h.owner_peer_type = recent.peer_type
|
||
AND h.owner_peer_id = recent.peer_id
|
||
ORDER BY recent.ord ASC`, peerTypes, peerIDs, int32(now), viewerUserID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get story peer projections: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make([]domain.PeerStoryProjection, 0, len(peers))
|
||
for rows.Next() {
|
||
var peerType string
|
||
var item domain.PeerStoryProjection
|
||
if err := rows.Scan(&peerType, &item.Peer.ID, &item.Recent.MaxID, &item.Hidden); err != nil {
|
||
return nil, fmt.Errorf("scan story peer projection: %w", err)
|
||
}
|
||
item.Peer.Type = domain.PeerType(peerType)
|
||
item.Recent.Peer = item.Peer
|
||
out = append(out, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("scan story peer projections: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *StoryStore) MarkRead(ctx context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) {
|
||
if viewerUserID == 0 {
|
||
return domain.StoryReadResult{}, domain.ErrStoryPeerInvalid
|
||
}
|
||
if err := validatePGStoryIdentity(peer, maxID); err != nil {
|
||
return domain.StoryReadResult{}, err
|
||
}
|
||
var gotMax, gotDate int
|
||
var advanced bool
|
||
err := s.db.QueryRow(ctx, `
|
||
WITH existing AS (
|
||
SELECT max_read_id
|
||
FROM story_read_states
|
||
WHERE viewer_user_id = $1
|
||
AND owner_peer_type = $2
|
||
AND owner_peer_id = $3
|
||
),
|
||
upsert AS (
|
||
INSERT INTO story_read_states (viewer_user_id, owner_peer_type, owner_peer_id, max_read_id, date)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (viewer_user_id, owner_peer_type, owner_peer_id) DO UPDATE SET
|
||
max_read_id = GREATEST(story_read_states.max_read_id, EXCLUDED.max_read_id),
|
||
date = CASE
|
||
WHEN EXCLUDED.max_read_id > story_read_states.max_read_id THEN EXCLUDED.date
|
||
ELSE story_read_states.date
|
||
END,
|
||
updated_at = CASE
|
||
WHEN EXCLUDED.max_read_id > story_read_states.max_read_id THEN now()
|
||
ELSE story_read_states.updated_at
|
||
END
|
||
RETURNING max_read_id, date
|
||
)
|
||
SELECT upsert.max_read_id, upsert.date, COALESCE((SELECT max_read_id FROM existing), 0) < $4
|
||
FROM upsert`, viewerUserID, string(peer.Type), peer.ID, int32(maxID), int32(date)).Scan(&gotMax, &gotDate, &advanced)
|
||
if err != nil {
|
||
return domain.StoryReadResult{}, fmt.Errorf("mark story read: %w", err)
|
||
}
|
||
return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, MaxReadID: gotMax, Advanced: advanced, Date: gotDate}, nil
|
||
}
|
||
|
||
func (s *StoryStore) IncrementViews(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) {
|
||
if viewerUserID == 0 {
|
||
return 0, domain.ErrStoryPeerInvalid
|
||
}
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return 0, err
|
||
}
|
||
ids, err := normalizePGStoryIDsNonEmpty(ids)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if peer.IsSelfUser(viewerUserID) {
|
||
return 0, nil
|
||
}
|
||
var created int
|
||
if err := s.db.QueryRow(ctx, `
|
||
WITH input AS (
|
||
SELECT DISTINCT unnest($5::int[]) AS story_id
|
||
),
|
||
visible AS (
|
||
SELECT s.story_id
|
||
FROM stories s
|
||
JOIN input i ON i.story_id = s.story_id
|
||
WHERE s.owner_peer_type = $2
|
||
AND s.owner_peer_id = $3
|
||
AND s.deleted = false
|
||
AND (s.expire_date > $4 OR s.pinned)
|
||
AND `+storyVisiblePredicate("$1")+`
|
||
),
|
||
inserted AS (
|
||
INSERT INTO story_views (owner_peer_type, owner_peer_id, story_id, viewer_user_id, date)
|
||
SELECT $2, $3, visible.story_id, $1, $4
|
||
FROM visible
|
||
ON CONFLICT DO NOTHING
|
||
RETURNING story_id
|
||
)
|
||
SELECT count(*)::int FROM inserted`, viewerUserID, string(peer.Type), peer.ID, int32(date), int32s(ids)).Scan(&created); err != nil {
|
||
return 0, fmt.Errorf("increment story views: %w", err)
|
||
}
|
||
return created, nil
|
||
}
|
||
|
||
func (s *StoryStore) SetReaction(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) {
|
||
if viewerUserID == 0 {
|
||
return domain.StoryReactionResult{}, domain.ErrStoryPeerInvalid
|
||
}
|
||
if err := validatePGStoryIdentity(peer, storyID); err != nil {
|
||
return domain.StoryReactionResult{}, err
|
||
}
|
||
if peer.IsSelfUser(viewerUserID) {
|
||
return domain.StoryReactionResult{}, domain.ErrStoryPeerInvalid
|
||
}
|
||
encodedReaction, err := encodeStoryReaction(reaction)
|
||
if err != nil {
|
||
return domain.StoryReactionResult{}, err
|
||
}
|
||
story, err := s.getVisibleStory(ctx, viewerUserID, peer, storyID)
|
||
if err != nil {
|
||
return domain.StoryReactionResult{}, err
|
||
}
|
||
if !story.Interactable(date) {
|
||
return domain.StoryReactionResult{}, domain.ErrStoryNotFound
|
||
}
|
||
var priorRaw string
|
||
var priorDate int
|
||
priorErr := s.db.QueryRow(ctx, `
|
||
SELECT COALESCE(reaction::text, '{}')::text, date
|
||
FROM story_views
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = $3
|
||
AND viewer_user_id = $4`, string(peer.Type), peer.ID, int32(storyID), viewerUserID).Scan(&priorRaw, &priorDate)
|
||
if priorErr != nil && !errors.Is(priorErr, pgx.ErrNoRows) {
|
||
return domain.StoryReactionResult{}, fmt.Errorf("load prior story reaction: %w", priorErr)
|
||
}
|
||
priorExists := priorErr == nil
|
||
prior, err := decodeStoryReaction(priorRaw)
|
||
if err != nil {
|
||
return domain.StoryReactionResult{}, fmt.Errorf("decode prior story reaction: %w", err)
|
||
}
|
||
changed := !samePGReaction(prior, reaction)
|
||
resultDate := date
|
||
if priorExists && !changed {
|
||
resultDate = priorDate
|
||
} else if _, err := s.db.Exec(ctx, `
|
||
INSERT INTO story_views (owner_peer_type, owner_peer_id, story_id, viewer_user_id, date, reaction)
|
||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||
ON CONFLICT (owner_peer_type, owner_peer_id, story_id, viewer_user_id) DO UPDATE SET
|
||
date = EXCLUDED.date,
|
||
reaction = EXCLUDED.reaction,
|
||
updated_at = now()`, string(peer.Type), peer.ID, int32(storyID), viewerUserID, int32(date), encodedReaction); err != nil {
|
||
return domain.StoryReactionResult{}, fmt.Errorf("set story reaction: %w", err)
|
||
}
|
||
stories := []domain.Story{story}
|
||
if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil {
|
||
return domain.StoryReactionResult{}, err
|
||
}
|
||
return domain.StoryReactionResult{
|
||
ViewerID: viewerUserID,
|
||
Peer: peer,
|
||
StoryID: storyID,
|
||
Reaction: clonePGReactionPtr(reaction),
|
||
Story: stories[0],
|
||
Changed: changed,
|
||
Date: resultDate,
|
||
}, nil
|
||
}
|
||
|
||
func (s *StoryStore) ListStoryViews(ctx context.Context, req domain.StoryViewListRequest) (domain.StoryViewList, error) {
|
||
if req.ViewerUserID == 0 {
|
||
return domain.StoryViewList{}, domain.ErrStoryPeerInvalid
|
||
}
|
||
if err := validatePGStoryIdentity(req.Owner, req.StoryID); err != nil {
|
||
return domain.StoryViewList{}, err
|
||
}
|
||
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
|
||
return domain.StoryViewList{}, err
|
||
}
|
||
if _, err := s.getOwnerStory(ctx, req.Owner, req.StoryID); err != nil {
|
||
return domain.StoryViewList{}, err
|
||
}
|
||
viewsCount, reactionsCount, err := s.storyViewCounts(ctx, req.Owner, req.StoryID)
|
||
if err != nil {
|
||
return domain.StoryViewList{}, err
|
||
}
|
||
forwardsCount, err := s.storyForwardCount(ctx, req.Owner, req.StoryID, req.ViewerUserID)
|
||
if err != nil {
|
||
return domain.StoryViewList{}, err
|
||
}
|
||
limit := clampPGStoryInteractionLimit(req.Limit)
|
||
cursor := parsePGStoryInteractionCursor(req.Offset)
|
||
interactionsFirst := req.ReactionsFirst || req.ForwardsFirst
|
||
query := strings.ToLower(strings.TrimSpace(req.Query))
|
||
querySet := query != ""
|
||
queryLike := "%" + escapeLike(query) + "%"
|
||
var count int
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT (count(*))::int
|
||
FROM story_views sv
|
||
JOIN users u ON u.id = sv.viewer_user_id
|
||
LEFT JOIN contacts c
|
||
ON c.user_id = $4
|
||
AND c.contact_user_id = sv.viewer_user_id
|
||
WHERE sv.owner_peer_type = $1
|
||
AND sv.owner_peer_id = $2
|
||
AND sv.story_id = $3
|
||
AND (NOT $5::boolean OR c.contact_user_id IS NOT NULL)
|
||
AND (
|
||
NOT $6::boolean
|
||
OR lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) LIKE $7 ESCAPE '\'
|
||
OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\'
|
||
OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\'
|
||
OR lower(u.username) LIKE $7 ESCAPE '\'
|
||
OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\'
|
||
)`, string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), req.ViewerUserID, req.JustContacts, querySet, queryLike).Scan(&count); err != nil {
|
||
return domain.StoryViewList{}, fmt.Errorf("count story views: %w", err)
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT
|
||
sv.viewer_user_id,
|
||
sv.date,
|
||
COALESCE(sv.reaction::text, '{}')::text,
|
||
false,
|
||
(
|
||
$1 = 'user'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM contact_blocks b
|
||
WHERE b.owner_user_id = $2
|
||
AND b.blocked_user_id = sv.viewer_user_id
|
||
)
|
||
)
|
||
FROM story_views sv
|
||
JOIN users u ON u.id = sv.viewer_user_id
|
||
LEFT JOIN contacts c
|
||
ON c.user_id = $4
|
||
AND c.contact_user_id = sv.viewer_user_id
|
||
WHERE sv.owner_peer_type = $1
|
||
AND sv.owner_peer_id = $2
|
||
AND sv.story_id = $3
|
||
AND (NOT $5::boolean OR c.contact_user_id IS NOT NULL)
|
||
AND (
|
||
NOT $6::boolean
|
||
OR lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) LIKE $7 ESCAPE '\'
|
||
OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\'
|
||
OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\'
|
||
OR lower(u.username) LIKE $7 ESCAPE '\'
|
||
OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\'
|
||
)
|
||
AND (
|
||
NOT $9::boolean
|
||
OR (CASE WHEN $8::boolean AND sv.reaction <> '{}'::jsonb AND sv.reaction <> 'null'::jsonb THEN 0 WHEN $8::boolean THEN 1 ELSE 0 END) > $10
|
||
OR (
|
||
(CASE WHEN $8::boolean AND sv.reaction <> '{}'::jsonb AND sv.reaction <> 'null'::jsonb THEN 0 WHEN $8::boolean THEN 1 ELSE 0 END) = $10
|
||
AND (sv.date < $11 OR (sv.date = $11 AND sv.viewer_user_id < $12))
|
||
)
|
||
)
|
||
ORDER BY
|
||
(CASE WHEN $8::boolean AND sv.reaction <> '{}'::jsonb AND sv.reaction <> 'null'::jsonb THEN 0 WHEN $8::boolean THEN 1 ELSE 0 END) ASC,
|
||
sv.date DESC,
|
||
sv.viewer_user_id DESC
|
||
LIMIT $13`, string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), req.ViewerUserID, req.JustContacts, querySet, queryLike, interactionsFirst, cursor.set, int32(cursor.group), int32(cursor.date), cursor.viewerID, int32(limit+1))
|
||
if err != nil {
|
||
return domain.StoryViewList{}, fmt.Errorf("list story views: %w", err)
|
||
}
|
||
views, err := scanPGStoryViews(rows, req.Owner, req.StoryID)
|
||
if err != nil {
|
||
return domain.StoryViewList{}, err
|
||
}
|
||
if !querySet && !req.JustContacts {
|
||
reposts, err := s.listStoryRepostViews(ctx, req, limit+1, cursor)
|
||
if err != nil {
|
||
return domain.StoryViewList{}, err
|
||
}
|
||
views = append(views, reposts...)
|
||
}
|
||
sortPGStoryViewsForList(views, req.ReactionsFirst, req.ForwardsFirst)
|
||
nextOffset := ""
|
||
if len(views) > limit {
|
||
views = views[:limit]
|
||
nextOffset = formatPGStoryInteractionCursor(views[len(views)-1], req.ReactionsFirst, req.ForwardsFirst)
|
||
}
|
||
if !querySet && !req.JustContacts {
|
||
count += forwardsCount
|
||
}
|
||
return domain.StoryViewList{
|
||
Count: count,
|
||
ViewsCount: viewsCount,
|
||
ForwardsCount: forwardsCount,
|
||
ReactionsCount: reactionsCount,
|
||
Views: views,
|
||
NextOffset: nextOffset,
|
||
}, nil
|
||
}
|
||
|
||
func (s *StoryStore) ListStoryReactions(ctx context.Context, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) {
|
||
if req.ViewerUserID == 0 {
|
||
return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid
|
||
}
|
||
if err := validatePGStoryIdentity(req.Owner, req.StoryID); err != nil {
|
||
return domain.StoryReactionList{}, err
|
||
}
|
||
if err := domain.ValidateStoryReactionInteractionOffset(req.Offset, req.ForwardsFirst); err != nil {
|
||
return domain.StoryReactionList{}, err
|
||
}
|
||
if _, err := s.getOwnerStory(ctx, req.Owner, req.StoryID); err != nil {
|
||
return domain.StoryReactionList{}, err
|
||
}
|
||
filterSet := req.Reaction != nil
|
||
reactionFilter, err := encodeStoryReaction(req.Reaction)
|
||
if err != nil {
|
||
return domain.StoryReactionList{}, err
|
||
}
|
||
var count int
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT (count(*))::int
|
||
FROM story_views
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = $3
|
||
AND reaction <> '{}'::jsonb
|
||
AND reaction <> 'null'::jsonb
|
||
AND (NOT $4::boolean OR reaction = $5::jsonb)`,
|
||
string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), filterSet, reactionFilter).Scan(&count); err != nil {
|
||
return domain.StoryReactionList{}, fmt.Errorf("count story reactions: %w", err)
|
||
}
|
||
if !filterSet {
|
||
forwardsCount, err := s.storyForwardCount(ctx, req.Owner, req.StoryID, req.ViewerUserID)
|
||
if err != nil {
|
||
return domain.StoryReactionList{}, err
|
||
}
|
||
count += forwardsCount
|
||
}
|
||
limit := clampPGStoryInteractionLimit(req.Limit)
|
||
cursor := parsePGStoryInteractionCursor(req.Offset)
|
||
reactionGroup := pgStoryViewSortGroup(domain.StoryView{Reaction: &domain.MessageReaction{}}, false, req.ForwardsFirst)
|
||
args := []any{string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), filterSet, reactionFilter, int32(limit + 1)}
|
||
cursorClause := ""
|
||
if cursor.set {
|
||
args = append(args, int32(reactionGroup), int32(cursor.group), int32(cursor.date), cursor.viewerID)
|
||
cursorClause = `
|
||
AND (
|
||
$7::int > $8::int
|
||
OR (
|
||
$7::int = $8::int
|
||
AND (
|
||
date < $9::int
|
||
OR (date = $9::int AND viewer_user_id < $10::bigint)
|
||
)
|
||
)
|
||
)`
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT viewer_user_id, date, COALESCE(reaction::text, '{}')::text, false, false
|
||
FROM story_views
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = $3
|
||
AND reaction <> '{}'::jsonb
|
||
AND reaction <> 'null'::jsonb
|
||
AND (NOT $4::boolean OR reaction = $5::jsonb)
|
||
`+cursorClause+`
|
||
ORDER BY date DESC, viewer_user_id DESC
|
||
LIMIT $6`, args...)
|
||
if err != nil {
|
||
return domain.StoryReactionList{}, fmt.Errorf("list story reactions: %w", err)
|
||
}
|
||
reactions, err := scanPGStoryViews(rows, req.Owner, req.StoryID)
|
||
if err != nil {
|
||
return domain.StoryReactionList{}, err
|
||
}
|
||
if !filterSet {
|
||
reposts, err := s.listStoryRepostViews(ctx, domain.StoryViewListRequest{
|
||
ViewerUserID: req.ViewerUserID,
|
||
Owner: req.Owner,
|
||
StoryID: req.StoryID,
|
||
ForwardsFirst: req.ForwardsFirst,
|
||
}, limit+1, cursor)
|
||
if err != nil {
|
||
return domain.StoryReactionList{}, err
|
||
}
|
||
reactions = append(reactions, reposts...)
|
||
}
|
||
sortPGStoryViewsForList(reactions, false, req.ForwardsFirst)
|
||
nextOffset := ""
|
||
if len(reactions) > limit {
|
||
reactions = reactions[:limit]
|
||
nextOffset = formatPGStoryInteractionCursor(reactions[len(reactions)-1], false, req.ForwardsFirst)
|
||
}
|
||
return domain.StoryReactionList{Count: count, Reactions: reactions, NextOffset: nextOffset}, nil
|
||
}
|
||
|
||
func (s *StoryStore) ListStoryPublicForwards(ctx context.Context, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) {
|
||
if req.ViewerUserID == 0 {
|
||
return domain.StoryPublicForwardList{}, domain.ErrStoryPeerInvalid
|
||
}
|
||
if err := validatePGStoryIdentity(req.Owner, req.StoryID); err != nil {
|
||
return domain.StoryPublicForwardList{}, err
|
||
}
|
||
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
|
||
return domain.StoryPublicForwardList{}, err
|
||
}
|
||
if _, err := s.getOwnerStory(ctx, req.Owner, req.StoryID); err != nil {
|
||
return domain.StoryPublicForwardList{}, err
|
||
}
|
||
count, err := s.storyForwardCount(ctx, req.Owner, req.StoryID, req.ViewerUserID)
|
||
if err != nil {
|
||
return domain.StoryPublicForwardList{}, err
|
||
}
|
||
limit := clampPGStoryInteractionLimit(req.Limit)
|
||
cursor := parsePGStoryInteractionCursor(req.Offset)
|
||
reposts, err := s.listStoryRepostViews(ctx, domain.StoryViewListRequest{
|
||
ViewerUserID: req.ViewerUserID,
|
||
Owner: req.Owner,
|
||
StoryID: req.StoryID,
|
||
Offset: req.Offset,
|
||
Limit: req.Limit,
|
||
ForwardsFirst: true,
|
||
}, limit+1, cursor)
|
||
if err != nil {
|
||
return domain.StoryPublicForwardList{}, err
|
||
}
|
||
sortPGStoryViewsForList(reposts, false, true)
|
||
nextOffset := ""
|
||
if len(reposts) > limit {
|
||
reposts = reposts[:limit]
|
||
nextOffset = formatPGStoryInteractionCursor(reposts[len(reposts)-1], false, true)
|
||
}
|
||
return domain.StoryPublicForwardList{Count: count, Forwards: reposts, NextOffset: nextOffset}, nil
|
||
}
|
||
|
||
func (s *StoryStore) ListStoryViewerIDs(ctx context.Context, owner domain.Peer, storyID, limit int) ([]int64, error) {
|
||
if err := validatePGStoryIdentity(owner, storyID); err != nil {
|
||
return nil, err
|
||
}
|
||
var exists bool
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM stories
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = $3
|
||
)`, string(owner.Type), owner.ID, int32(storyID)).Scan(&exists); err != nil {
|
||
return nil, fmt.Errorf("check story for viewer ids: %w", err)
|
||
}
|
||
if !exists {
|
||
return nil, domain.ErrStoryNotFound
|
||
}
|
||
limit = clampPGStoryPrivacyFanoutLimit(limit)
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT viewer_user_id
|
||
FROM (
|
||
SELECT viewer_user_id
|
||
FROM story_views
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = $3
|
||
UNION
|
||
SELECT viewer_user_id
|
||
FROM story_exposures
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = $3
|
||
) viewers
|
||
WHERE viewer_user_id <> 0
|
||
ORDER BY viewer_user_id ASC
|
||
LIMIT $4`, string(owner.Type), owner.ID, int32(storyID), int32(limit))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list story viewer ids: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
ids := make([]int64, 0)
|
||
for rows.Next() {
|
||
var id int64
|
||
if err := rows.Scan(&id); err != nil {
|
||
return nil, fmt.Errorf("scan story viewer id: %w", err)
|
||
}
|
||
if id != 0 {
|
||
ids = append(ids, id)
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("iterate story viewer ids: %w", err)
|
||
}
|
||
return ids, nil
|
||
}
|
||
|
||
func (s *StoryStore) recordStoryExposures(ctx context.Context, viewerUserID int64, stories []domain.Story) error {
|
||
if viewerUserID == 0 || len(stories) == 0 {
|
||
return nil
|
||
}
|
||
peerTypes := make([]string, 0, len(stories))
|
||
peerIDs := make([]int64, 0, len(stories))
|
||
storyIDs := make([]int32, 0, len(stories))
|
||
dates := make([]int32, 0, len(stories))
|
||
type exposureKey struct {
|
||
peerType domain.PeerType
|
||
peerID int64
|
||
storyID int
|
||
}
|
||
seen := make(map[exposureKey]struct{}, len(stories))
|
||
for _, story := range stories {
|
||
if story.ID <= 0 || story.Owner.ID == 0 {
|
||
continue
|
||
}
|
||
if story.Owner.Type == domain.PeerTypeUser && story.Owner.ID == viewerUserID {
|
||
continue
|
||
}
|
||
key := exposureKey{peerType: story.Owner.Type, peerID: story.Owner.ID, storyID: story.ID}
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
peerTypes = append(peerTypes, string(story.Owner.Type))
|
||
peerIDs = append(peerIDs, story.Owner.ID)
|
||
storyIDs = append(storyIDs, int32(story.ID))
|
||
dates = append(dates, int32(story.Date))
|
||
}
|
||
if len(storyIDs) == 0 {
|
||
return nil
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
INSERT INTO story_exposures (owner_peer_type, owner_peer_id, story_id, viewer_user_id, date)
|
||
SELECT t.owner_peer_type, t.owner_peer_id, t.story_id, $1, t.date
|
||
FROM unnest($2::text[], $3::bigint[], $4::int[], $5::int[]) AS t(owner_peer_type, owner_peer_id, story_id, date)
|
||
ON CONFLICT (owner_peer_type, owner_peer_id, story_id, viewer_user_id) DO UPDATE SET
|
||
date = GREATEST(story_exposures.date, EXCLUDED.date),
|
||
updated_at = now()`, viewerUserID, peerTypes, peerIDs, storyIDs, dates); err != nil {
|
||
return fmt.Errorf("record story exposures: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *StoryStore) EditStory(ctx context.Context, req domain.StoryEditRequest) (domain.StoryEditResult, error) {
|
||
if err := validatePGStoryIdentity(req.Owner, req.ID); err != nil {
|
||
return domain.StoryEditResult{}, err
|
||
}
|
||
current, err := s.getOwnerStory(ctx, req.Owner, req.ID)
|
||
if err != nil {
|
||
return domain.StoryEditResult{}, err
|
||
}
|
||
updated := clonePGStory(current)
|
||
if req.UpdateMedia {
|
||
updated.Media = req.Media
|
||
}
|
||
if req.UpdateCaption {
|
||
updated.Caption = req.Caption
|
||
updated.Entities = append([]domain.MessageEntity(nil), req.Entities...)
|
||
}
|
||
if req.UpdatePrivacy {
|
||
updated.Public = req.Public
|
||
updated.CloseFriends = req.CloseFriends
|
||
updated.Contacts = req.Contacts
|
||
updated.SelectedContacts = req.SelectedContacts
|
||
updated.PrivacyRules = clonePGPrivacyRules(req.PrivacyRules)
|
||
updated.AllowUserIDs = append([]int64(nil), req.AllowUserIDs...)
|
||
updated.DisallowUserIDs = append([]int64(nil), req.DisallowUserIDs...)
|
||
}
|
||
if req.UpdateMediaAreas {
|
||
updated.MediaAreas = clonePGStoryMediaAreas(req.MediaAreas)
|
||
}
|
||
if reflect.DeepEqual(current, updated) {
|
||
return domain.StoryEditResult{}, domain.ErrStoryNotModified
|
||
}
|
||
updated.Edited = true
|
||
entities, err := encodeMessageEntities(updated.Entities)
|
||
if err != nil {
|
||
return domain.StoryEditResult{}, fmt.Errorf("encode story entities: %w", err)
|
||
}
|
||
media, err := encodeMessageMedia(updated.Media)
|
||
if err != nil {
|
||
return domain.StoryEditResult{}, fmt.Errorf("encode story media: %w", err)
|
||
}
|
||
mediaAreas, err := encodeStoryMediaAreas(updated.MediaAreas)
|
||
if err != nil {
|
||
return domain.StoryEditResult{}, fmt.Errorf("encode story media areas: %w", err)
|
||
}
|
||
privacyRules, err := encodePrivacyRules(updated.PrivacyRules)
|
||
if err != nil {
|
||
return domain.StoryEditResult{}, fmt.Errorf("encode story privacy rules: %w", err)
|
||
}
|
||
row := s.db.QueryRow(ctx, `
|
||
UPDATE stories
|
||
SET public = $4,
|
||
close_friends = $5,
|
||
contacts = $6,
|
||
selected_contacts = $7,
|
||
edited = true,
|
||
privacy_rules = $8::jsonb,
|
||
allow_user_ids = $9::bigint[],
|
||
disallow_user_ids = $10::bigint[],
|
||
caption = $11,
|
||
entities = $12::jsonb,
|
||
media = $13::jsonb,
|
||
media_areas = $14::jsonb,
|
||
updated_at = now()
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = $3
|
||
AND deleted = false
|
||
RETURNING `+storyReturningColumns,
|
||
string(req.Owner.Type), req.Owner.ID, int32(req.ID),
|
||
updated.Public, updated.CloseFriends, updated.Contacts, updated.SelectedContacts,
|
||
privacyRules, nonNullInt64s(updated.AllowUserIDs), nonNullInt64s(updated.DisallowUserIDs),
|
||
updated.Caption, entities, media, mediaAreas)
|
||
out, err := scanPGStory(row, req.Owner.ID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return domain.StoryEditResult{}, domain.ErrStoryNotFound
|
||
}
|
||
if err != nil {
|
||
return domain.StoryEditResult{}, fmt.Errorf("edit story: %w", err)
|
||
}
|
||
return domain.StoryEditResult{Story: out, Previous: clonePGStory(current)}, nil
|
||
}
|
||
|
||
func (s *StoryStore) DeleteStories(ctx context.Context, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) {
|
||
_ = date
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return domain.StoryMutationResult{}, err
|
||
}
|
||
ids, err := normalizePGStoryIDsNonEmpty(ids)
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, err
|
||
}
|
||
beforeRows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = ANY($3::int[])
|
||
AND deleted = false`, string(peer.Type), peer.ID, int32s(ids))
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, fmt.Errorf("load stories before delete: %w", err)
|
||
}
|
||
before, err := scanPGStories(beforeRows, peer.ID)
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, err
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
UPDATE stories
|
||
SET deleted = true,
|
||
pinned = false,
|
||
pinned_to_top_order = 0,
|
||
updated_at = now()
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = ANY($3::int[])
|
||
AND deleted = false
|
||
RETURNING `+storyReturningColumns, string(peer.Type), peer.ID, int32s(ids))
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, fmt.Errorf("delete stories: %w", err)
|
||
}
|
||
stories, err := scanPGStories(rows, peer.ID)
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, err
|
||
}
|
||
previousByID := make(map[int]domain.Story, len(before))
|
||
for _, story := range before {
|
||
previousByID[story.ID] = story
|
||
}
|
||
previous := make([]domain.Story, 0, len(stories))
|
||
for _, story := range stories {
|
||
if prev, ok := previousByID[story.ID]; ok {
|
||
previous = append(previous, prev)
|
||
}
|
||
}
|
||
return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...), Stories: stories, Previous: previous}, nil
|
||
}
|
||
|
||
func (s *StoryStore) TogglePinned(ctx context.Context, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) {
|
||
_ = date
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return domain.StoryMutationResult{}, err
|
||
}
|
||
ids, err := normalizePGStoryIDs(ids)
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, err
|
||
}
|
||
beforeRows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = ANY($3::int[])
|
||
AND deleted = false
|
||
AND pinned <> $4`, string(peer.Type), peer.ID, int32s(ids), pinned)
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, fmt.Errorf("load stories before toggle pinned: %w", err)
|
||
}
|
||
before, err := scanPGStories(beforeRows, peer.ID)
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, err
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
UPDATE stories
|
||
SET pinned = $4,
|
||
pinned_to_top_order = CASE WHEN $4::boolean THEN pinned_to_top_order ELSE 0 END,
|
||
updated_at = now()
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = ANY($3::int[])
|
||
AND deleted = false
|
||
AND pinned <> $4
|
||
RETURNING `+storyReturningColumns, string(peer.Type), peer.ID, int32s(ids), pinned)
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, fmt.Errorf("toggle story pinned: %w", err)
|
||
}
|
||
stories, err := scanPGStories(rows, peer.ID)
|
||
if err != nil {
|
||
return domain.StoryMutationResult{}, err
|
||
}
|
||
previousByID := make(map[int]domain.Story, len(before))
|
||
for _, story := range before {
|
||
previousByID[story.ID] = story
|
||
}
|
||
previous := make([]domain.Story, 0, len(stories))
|
||
for _, story := range stories {
|
||
if prev, ok := previousByID[story.ID]; ok {
|
||
previous = append(previous, prev)
|
||
}
|
||
}
|
||
return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...), Stories: stories, Previous: previous}, nil
|
||
}
|
||
|
||
func (s *StoryStore) TogglePinnedToTop(ctx context.Context, peer domain.Peer, ids []int) error {
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return err
|
||
}
|
||
ids, err := normalizePGStoryPinnedToTopIDs(ids)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return withTx(ctx, s.db, "toggle story pinned to top", func(tx pgx.Tx) error {
|
||
if len(ids) > 0 {
|
||
var found int
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT count(*)::int
|
||
FROM stories
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = ANY($3::int[])
|
||
AND deleted = false
|
||
AND pinned = true`, string(peer.Type), peer.ID, int32s(ids)).Scan(&found); err != nil {
|
||
return fmt.Errorf("count pinned-to-top candidates: %w", err)
|
||
}
|
||
if found != len(ids) {
|
||
return domain.ErrStoryIDInvalid
|
||
}
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE stories
|
||
SET pinned_to_top_order = 0,
|
||
updated_at = now()
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND pinned_to_top_order <> 0`, string(peer.Type), peer.ID); err != nil {
|
||
return fmt.Errorf("clear pinned-to-top order: %w", err)
|
||
}
|
||
for i, id := range ids {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE stories
|
||
SET pinned_to_top_order = $4,
|
||
updated_at = now()
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = $3
|
||
AND deleted = false
|
||
AND pinned = true`, string(peer.Type), peer.ID, int32(id), int32(i+1))
|
||
if err != nil {
|
||
return fmt.Errorf("set pinned-to-top order: %w", err)
|
||
}
|
||
if tag.RowsAffected() != 1 {
|
||
return domain.ErrStoryIDInvalid
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (s *StoryStore) SetPeerHidden(ctx context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error {
|
||
if viewerUserID == 0 {
|
||
return domain.ErrStoryPeerInvalid
|
||
}
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return err
|
||
}
|
||
if hidden {
|
||
_, err := s.db.Exec(ctx, `
|
||
INSERT INTO story_hidden_peers (viewer_user_id, owner_peer_type, owner_peer_id)
|
||
VALUES ($1, $2, $3)
|
||
ON CONFLICT (viewer_user_id, owner_peer_type, owner_peer_id) DO UPDATE SET updated_at = now()`,
|
||
viewerUserID, string(peer.Type), peer.ID)
|
||
if err != nil {
|
||
return fmt.Errorf("set story peer hidden: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
if _, err := s.db.Exec(ctx, `
|
||
DELETE FROM story_hidden_peers
|
||
WHERE viewer_user_id = $1
|
||
AND owner_peer_type = $2
|
||
AND owner_peer_id = $3`, viewerUserID, string(peer.Type), peer.ID); err != nil {
|
||
return fmt.Errorf("clear story peer hidden: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *StoryStore) getVisibleStory(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int) (domain.Story, error) {
|
||
row := s.db.QueryRow(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.story_id = $3
|
||
AND s.deleted = false
|
||
AND `+storyVisiblePredicate("$4"), string(peer.Type), peer.ID, int32(storyID), viewerUserID)
|
||
story, err := scanPGStory(row, viewerUserID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return domain.Story{}, domain.ErrStoryNotFound
|
||
}
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("get visible story: %w", err)
|
||
}
|
||
return story, nil
|
||
}
|
||
|
||
func (s *StoryStore) storyByRandomID(ctx context.Context, peer domain.Peer, randomID int64) (domain.Story, bool, error) {
|
||
row := s.db.QueryRow(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.random_id = $3`, string(peer.Type), peer.ID, randomID)
|
||
story, err := scanPGStory(row, peer.ID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return domain.Story{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return domain.Story{}, false, fmt.Errorf("load story by random id: %w", err)
|
||
}
|
||
return story, true, nil
|
||
}
|
||
|
||
func (s *StoryStore) getOwnerStory(ctx context.Context, peer domain.Peer, storyID int) (domain.Story, error) {
|
||
row := s.db.QueryRow(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.owner_peer_type = $1
|
||
AND s.owner_peer_id = $2
|
||
AND s.story_id = $3
|
||
AND s.deleted = false`, string(peer.Type), peer.ID, int32(storyID))
|
||
story, err := scanPGStory(row, peer.ID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return domain.Story{}, domain.ErrStoryNotFound
|
||
}
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("get owner story: %w", err)
|
||
}
|
||
return story, nil
|
||
}
|
||
|
||
func (s *StoryStore) getReadState(ctx context.Context, viewerUserID int64, peer domain.Peer) (domain.StoryReadState, error) {
|
||
if viewerUserID == 0 {
|
||
return domain.StoryReadState{ViewerID: viewerUserID, Peer: peer}, nil
|
||
}
|
||
var state domain.StoryReadState
|
||
var peerType string
|
||
err := s.db.QueryRow(ctx, `
|
||
SELECT viewer_user_id, owner_peer_type, owner_peer_id, max_read_id, date
|
||
FROM story_read_states
|
||
WHERE viewer_user_id = $1
|
||
AND owner_peer_type = $2
|
||
AND owner_peer_id = $3`, viewerUserID, string(peer.Type), peer.ID).Scan(
|
||
&state.ViewerID, &peerType, &state.Peer.ID, &state.MaxReadID, &state.Date,
|
||
)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return domain.StoryReadState{ViewerID: viewerUserID, Peer: peer}, nil
|
||
}
|
||
if err != nil {
|
||
return domain.StoryReadState{}, fmt.Errorf("get story read state: %w", err)
|
||
}
|
||
state.Peer.Type = domain.PeerType(peerType)
|
||
return state, nil
|
||
}
|
||
|
||
func (s *StoryStore) populateStoryViewState(ctx context.Context, viewerUserID int64, stories []domain.Story) error {
|
||
if len(stories) == 0 {
|
||
return nil
|
||
}
|
||
peerTypes := make([]string, 0, len(stories))
|
||
peerIDs := make([]int64, 0, len(stories))
|
||
storyIDs := make([]int32, 0, len(stories))
|
||
for _, story := range stories {
|
||
peerTypes = append(peerTypes, string(story.Owner.Type))
|
||
peerIDs = append(peerIDs, story.Owner.ID)
|
||
storyIDs = append(storyIDs, int32(story.ID))
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
WITH input AS (
|
||
SELECT p.peer_type, i.peer_id, sid.story_id, p.ord
|
||
FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord)
|
||
JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord)
|
||
JOIN unnest($3::int[]) WITH ORDINALITY AS sid(story_id, ord) USING (ord)
|
||
)
|
||
SELECT input.ord, v.viewer_user_id, v.date, COALESCE(v.reaction::text, '{}')::text
|
||
FROM input
|
||
JOIN story_views v
|
||
ON v.owner_peer_type = input.peer_type
|
||
AND v.owner_peer_id = input.peer_id
|
||
AND v.story_id = input.story_id
|
||
ORDER BY input.ord ASC, v.date DESC, v.viewer_user_id DESC`, peerTypes, peerIDs, storyIDs)
|
||
if err != nil {
|
||
return fmt.Errorf("load story view state: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
for i := range stories {
|
||
stories[i].Views = domain.StoryViews{}
|
||
stories[i].SentReaction = nil
|
||
}
|
||
for rows.Next() {
|
||
var ord int
|
||
var viewerID int64
|
||
var date int
|
||
var reactionRaw string
|
||
if err := rows.Scan(&ord, &viewerID, &date, &reactionRaw); err != nil {
|
||
return fmt.Errorf("scan story view state: %w", err)
|
||
}
|
||
_ = date
|
||
idx := ord - 1
|
||
if idx < 0 || idx >= len(stories) {
|
||
continue
|
||
}
|
||
view := &stories[idx].Views
|
||
view.ViewsCount++
|
||
view.HasViewers = true
|
||
if len(view.RecentViewers) < 3 {
|
||
view.RecentViewers = append(view.RecentViewers, viewerID)
|
||
}
|
||
reaction, err := decodeStoryReaction(reactionRaw)
|
||
if err != nil {
|
||
return fmt.Errorf("decode story view reaction: %w", err)
|
||
}
|
||
if reaction != nil {
|
||
view.ReactionsCount++
|
||
addPGStoryReactionCount(view, *reaction)
|
||
}
|
||
if viewerID == viewerUserID {
|
||
stories[idx].SentReaction = reaction
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return fmt.Errorf("scan story view states: %w", err)
|
||
}
|
||
repostRows, err := s.db.Query(ctx, `
|
||
WITH input AS (
|
||
SELECT p.peer_type, i.peer_id, sid.story_id, p.ord
|
||
FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord)
|
||
JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord)
|
||
JOIN unnest($3::int[]) WITH ORDINALITY AS sid(story_id, ord) USING (ord)
|
||
)
|
||
SELECT input.ord, COUNT(r.story_id)::int
|
||
FROM input
|
||
LEFT JOIN stories r
|
||
ON r.deleted = false
|
||
AND r.public = true
|
||
AND `+storyForwardSourceTypeSQL("r")+` = input.peer_type
|
||
AND `+storyForwardSourceIDSQL("r")+` = input.peer_id
|
||
AND (r.fwd_from->>'StoryID')::int = input.story_id
|
||
AND `+storyPublicRepostVisiblePredicateFor("r", "$4")+`
|
||
GROUP BY input.ord
|
||
ORDER BY input.ord ASC`, peerTypes, peerIDs, storyIDs, viewerUserID)
|
||
if err != nil {
|
||
return fmt.Errorf("load story forward state: %w", err)
|
||
}
|
||
defer repostRows.Close()
|
||
for repostRows.Next() {
|
||
var ord, count int
|
||
if err := repostRows.Scan(&ord, &count); err != nil {
|
||
return fmt.Errorf("scan story forward state: %w", err)
|
||
}
|
||
idx := ord - 1
|
||
if idx < 0 || idx >= len(stories) {
|
||
continue
|
||
}
|
||
stories[idx].Views.ForwardsCount = count
|
||
}
|
||
if err := repostRows.Err(); err != nil {
|
||
return fmt.Errorf("scan story forward states: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *StoryStore) storyViewCounts(ctx context.Context, peer domain.Peer, storyID int) (viewsCount, reactionsCount int, err error) {
|
||
err = s.db.QueryRow(ctx, `
|
||
SELECT
|
||
(count(*))::int,
|
||
(count(*) FILTER (WHERE reaction <> '{}'::jsonb AND reaction <> 'null'::jsonb))::int
|
||
FROM story_views
|
||
WHERE owner_peer_type = $1
|
||
AND owner_peer_id = $2
|
||
AND story_id = $3`, string(peer.Type), peer.ID, int32(storyID)).Scan(&viewsCount, &reactionsCount)
|
||
if err != nil {
|
||
return 0, 0, fmt.Errorf("count story views: %w", err)
|
||
}
|
||
return viewsCount, reactionsCount, nil
|
||
}
|
||
|
||
func (s *StoryStore) storyForwardCount(ctx context.Context, peer domain.Peer, storyID int, viewerUserID int64) (int, error) {
|
||
var count int
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT COUNT(*)::int
|
||
FROM stories r
|
||
WHERE r.deleted = false
|
||
AND r.public = true
|
||
AND `+storyForwardSourceTypeSQL("r")+` = $1
|
||
AND `+storyForwardSourceIDSQL("r")+` = $2
|
||
AND (r.fwd_from->>'StoryID')::int = $3
|
||
AND `+storyPublicRepostVisiblePredicateFor("r", "$4"),
|
||
string(peer.Type), peer.ID, int32(storyID), viewerUserID).Scan(&count); err != nil {
|
||
return 0, fmt.Errorf("count story forwards: %w", err)
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
func (s *StoryStore) listStoryRepostViews(ctx context.Context, req domain.StoryViewListRequest, limit int, cursor storyInteractionCursor) ([]domain.StoryView, error) {
|
||
limit = clampPGStoryProbeLimit(limit)
|
||
group := pgStoryViewSortGroup(domain.StoryView{Repost: &domain.Story{}}, req.ReactionsFirst, req.ForwardsFirst)
|
||
args := []any{string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), req.ViewerUserID, int32(limit)}
|
||
cursorClause := ""
|
||
if cursor.set {
|
||
args = append(args, int32(group), int32(cursor.group), int32(cursor.date), cursor.viewerID, int32(cursor.messageID))
|
||
cursorClause = fmt.Sprintf(`
|
||
AND (
|
||
$6::int > $7::int
|
||
OR (
|
||
$6::int = $7::int
|
||
AND (
|
||
s.date < $8::int
|
||
OR (
|
||
s.date = $8::int
|
||
AND (
|
||
(CASE WHEN s.owner_peer_type = 'channel' THEN -s.owner_peer_id ELSE s.owner_peer_id END) < $9::bigint
|
||
OR (
|
||
(CASE WHEN s.owner_peer_type = 'channel' THEN -s.owner_peer_id ELSE s.owner_peer_id END) = $9::bigint
|
||
AND 0 < $10::int
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)`)
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+storySelectColumns+`
|
||
FROM stories s
|
||
WHERE s.deleted = false
|
||
AND s.public = true
|
||
AND `+storyForwardSourceTypeSQL("s")+` = $1
|
||
AND `+storyForwardSourceIDSQL("s")+` = $2
|
||
AND (s.fwd_from->>'StoryID')::int = $3
|
||
AND `+storyPublicRepostVisiblePredicateFor("s", "$4")+cursorClause+`
|
||
ORDER BY s.date DESC, (CASE WHEN s.owner_peer_type = 'channel' THEN -s.owner_peer_id ELSE s.owner_peer_id END) DESC
|
||
LIMIT $5`, args...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list story repost views: %w", err)
|
||
}
|
||
reposts, err := scanPGStories(rows, req.ViewerUserID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]domain.StoryView, 0, len(reposts))
|
||
for _, repost := range reposts {
|
||
item := clonePGStory(repost)
|
||
out = append(out, domain.StoryView{
|
||
Owner: req.Owner,
|
||
StoryID: req.StoryID,
|
||
ViewerID: pgStoryPeerCursorKey(item.Owner),
|
||
Date: item.Date,
|
||
Repost: &item,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func scanPGStoryViews(rows pgx.Rows, owner domain.Peer, storyID int) ([]domain.StoryView, error) {
|
||
defer rows.Close()
|
||
out := make([]domain.StoryView, 0)
|
||
for rows.Next() {
|
||
var view domain.StoryView
|
||
var reactionRaw string
|
||
if err := rows.Scan(
|
||
&view.ViewerID,
|
||
&view.Date,
|
||
&reactionRaw,
|
||
&view.Blocked,
|
||
&view.BlockedMyStoriesFrom,
|
||
); err != nil {
|
||
return nil, fmt.Errorf("scan story view: %w", err)
|
||
}
|
||
reaction, err := decodeStoryReaction(reactionRaw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decode story list reaction: %w", err)
|
||
}
|
||
view.Owner = owner
|
||
view.StoryID = storyID
|
||
view.Reaction = reaction
|
||
out = append(out, view)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("scan story views: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func scanPGStories(rows pgx.Rows, viewerUserID int64) ([]domain.Story, error) {
|
||
defer rows.Close()
|
||
stories := make([]domain.Story, 0)
|
||
for rows.Next() {
|
||
story, err := scanPGStory(rows, viewerUserID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("scan story: %w", err)
|
||
}
|
||
stories = append(stories, story)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("scan stories: %w", err)
|
||
}
|
||
return stories, nil
|
||
}
|
||
|
||
func scanPGStory(row rowScanner, viewerUserID int64) (domain.Story, error) {
|
||
var story domain.Story
|
||
var peerType string
|
||
var privacyRulesJSON, entitiesJSON, mediaJSON, mediaAreasJSON, forwardJSON string
|
||
if err := row.Scan(
|
||
&peerType,
|
||
&story.Owner.ID,
|
||
&story.ID,
|
||
&story.RandomID,
|
||
&story.Date,
|
||
&story.ExpireDate,
|
||
&story.Deleted,
|
||
&story.Pinned,
|
||
&story.PinnedToTopOrder,
|
||
&story.Public,
|
||
&story.CloseFriends,
|
||
&story.Contacts,
|
||
&story.SelectedContacts,
|
||
&story.NoForwards,
|
||
&story.Edited,
|
||
&privacyRulesJSON,
|
||
&story.AllowUserIDs,
|
||
&story.DisallowUserIDs,
|
||
&story.Caption,
|
||
&entitiesJSON,
|
||
&mediaJSON,
|
||
&mediaAreasJSON,
|
||
&forwardJSON,
|
||
); err != nil {
|
||
return domain.Story{}, err
|
||
}
|
||
story.Owner.Type = domain.PeerType(peerType)
|
||
privacyRules, err := decodePrivacyRulesJSON(privacyRulesJSON)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("decode story privacy rules: %w", err)
|
||
}
|
||
story.PrivacyRules = privacyRules
|
||
entities, err := decodeMessageEntities(entitiesJSON)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("decode story entities: %w", err)
|
||
}
|
||
story.Entities = entities
|
||
media, err := decodeMessageMedia(mediaJSON)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("decode story media: %w", err)
|
||
}
|
||
story.Media = media
|
||
mediaAreas, err := decodeStoryMediaAreas(mediaAreasJSON)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("decode story media areas: %w", err)
|
||
}
|
||
story.MediaAreas = mediaAreas
|
||
forward, err := decodeStoryForward(forwardJSON)
|
||
if err != nil {
|
||
return domain.Story{}, fmt.Errorf("decode story forward: %w", err)
|
||
}
|
||
story.Forward = forward
|
||
story.Out = story.Owner.Type == domain.PeerTypeUser && story.Owner.ID == viewerUserID
|
||
return story, nil
|
||
}
|
||
|
||
func groupPGPeerStories(stories []domain.Story, reads []domain.StoryReadState) []domain.PeerStories {
|
||
readByPeer := make(map[domain.Peer]int, len(reads))
|
||
for _, read := range reads {
|
||
readByPeer[read.Peer] = read.MaxReadID
|
||
}
|
||
index := make(map[domain.Peer]int)
|
||
out := make([]domain.PeerStories, 0)
|
||
for _, story := range stories {
|
||
i, ok := index[story.Owner]
|
||
if !ok {
|
||
i = len(out)
|
||
index[story.Owner] = i
|
||
out = append(out, domain.PeerStories{Peer: story.Owner, MaxReadID: readByPeer[story.Owner]})
|
||
}
|
||
out[i].Stories = append(out[i].Stories, clonePGStory(story))
|
||
}
|
||
for i := range out {
|
||
sort.Slice(out[i].Stories, func(a, b int) bool {
|
||
if out[i].Stories[a].ID != out[i].Stories[b].ID {
|
||
return out[i].Stories[a].ID < out[i].Stories[b].ID
|
||
}
|
||
return out[i].Stories[a].Date < out[i].Stories[b].Date
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func validatePGStoryIdentity(peer domain.Peer, id int) error {
|
||
if err := validatePGStoryPeer(peer); err != nil {
|
||
return err
|
||
}
|
||
if id <= 0 || id > domain.MaxStoryID {
|
||
return domain.ErrStoryIDInvalid
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validatePGStoryPeer(peer domain.Peer) error {
|
||
switch peer.Type {
|
||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||
if peer.ID > 0 {
|
||
return nil
|
||
}
|
||
}
|
||
return domain.ErrStoryPeerInvalid
|
||
}
|
||
|
||
func validatePGStoryIDs(ids []int) error {
|
||
if len(ids) > domain.MaxStoryIDs {
|
||
return domain.ErrStoryIDInvalid
|
||
}
|
||
for _, id := range ids {
|
||
if id <= 0 || id > domain.MaxStoryID {
|
||
return domain.ErrStoryIDInvalid
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validatePGStoryIDsNonEmpty(ids []int) error {
|
||
if len(ids) == 0 {
|
||
return domain.ErrStoryIDInvalid
|
||
}
|
||
return validatePGStoryIDs(ids)
|
||
}
|
||
|
||
func normalizePGStoryIDsNonEmpty(ids []int) ([]int, error) {
|
||
if err := validatePGStoryIDsNonEmpty(ids); err != nil {
|
||
return nil, err
|
||
}
|
||
return normalizePGStoryIDsUnchecked(ids), nil
|
||
}
|
||
|
||
func normalizePGStoryIDs(ids []int) ([]int, error) {
|
||
if err := validatePGStoryIDs(ids); err != nil {
|
||
return nil, err
|
||
}
|
||
return normalizePGStoryIDsUnchecked(ids), nil
|
||
}
|
||
|
||
func normalizePGStoryPinnedToTopIDs(ids []int) ([]int, error) {
|
||
ids, err := normalizePGStoryIDs(ids)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(ids) > domain.MaxStoryPinnedToTop {
|
||
return nil, domain.ErrStoryIDInvalid
|
||
}
|
||
return ids, nil
|
||
}
|
||
|
||
func normalizePGStoryIDsUnchecked(ids []int) []int {
|
||
seen := make(map[int]struct{}, len(ids))
|
||
out := make([]int, 0, len(ids))
|
||
for _, id := range ids {
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
out = append(out, id)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func clampPGStoryLimit(limit int) int {
|
||
if limit <= 0 || limit > domain.MaxStoryListLimit {
|
||
return domain.MaxStoryListLimit
|
||
}
|
||
return limit
|
||
}
|
||
|
||
func clampPGStoryProbeLimit(limit int) int {
|
||
max := domain.MaxStoryListLimit + 1
|
||
if limit <= 0 || limit > max {
|
||
return max
|
||
}
|
||
return limit
|
||
}
|
||
|
||
func clampPGStoryInteractionLimit(limit int) int {
|
||
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
|
||
return domain.MaxStoryInteractionListLimit
|
||
}
|
||
return limit
|
||
}
|
||
|
||
func clampPGStoryPrivacyFanoutLimit(limit int) int {
|
||
if limit <= 0 || limit > domain.MaxStoryPrivacyFanoutTargets {
|
||
return domain.MaxStoryPrivacyFanoutTargets
|
||
}
|
||
return limit
|
||
}
|
||
|
||
type storyInteractionCursor struct {
|
||
set bool
|
||
group int
|
||
date int
|
||
viewerID int64
|
||
messageID int
|
||
}
|
||
|
||
func parsePGStoryInteractionCursor(offset string) storyInteractionCursor {
|
||
if offset == "" {
|
||
return storyInteractionCursor{}
|
||
}
|
||
parts := strings.Split(offset, ":")
|
||
if len(parts) != 3 && len(parts) != 4 {
|
||
return storyInteractionCursor{}
|
||
}
|
||
group, err1 := strconv.Atoi(parts[0])
|
||
date, err2 := strconv.Atoi(parts[1])
|
||
viewerID, err3 := strconv.ParseInt(parts[2], 10, 64)
|
||
var messageID int
|
||
var err4 error
|
||
if len(parts) == 4 {
|
||
messageID, err4 = strconv.Atoi(parts[3])
|
||
}
|
||
if err1 != nil || err2 != nil || err3 != nil || err4 != nil || group < 0 || viewerID == 0 || messageID < 0 {
|
||
return storyInteractionCursor{}
|
||
}
|
||
return storyInteractionCursor{set: true, group: group, date: date, viewerID: viewerID, messageID: messageID}
|
||
}
|
||
|
||
func formatPGStoryInteractionCursor(view domain.StoryView, reactionsFirst, forwardsFirst bool) string {
|
||
group := pgStoryViewSortGroup(view, reactionsFirst, forwardsFirst)
|
||
out := strconv.Itoa(group) + ":" + strconv.Itoa(view.Date) + ":" + strconv.FormatInt(pgStoryViewCursorKey(view), 10)
|
||
if id := pgStoryViewCursorMessageID(view); id > 0 {
|
||
out += ":" + strconv.Itoa(id)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func pgStoryViewSortGroup(view domain.StoryView, reactionsFirst, forwardsFirst bool) int {
|
||
if forwardsFirst {
|
||
if view.Repost != nil || view.PublicForward != nil {
|
||
return 0
|
||
}
|
||
return 1
|
||
}
|
||
if reactionsFirst && view.Reaction == nil && view.Repost == nil && view.PublicForward == nil {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func pgStoryViewCursorKey(view domain.StoryView) int64 {
|
||
if view.PublicForward != nil {
|
||
return pgStoryPeerCursorKey(domain.Peer{Type: domain.PeerTypeChannel, ID: view.PublicForward.Message.ChannelID})
|
||
}
|
||
if view.Repost != nil {
|
||
return pgStoryPeerCursorKey(view.Repost.Owner)
|
||
}
|
||
return view.ViewerID
|
||
}
|
||
|
||
func pgStoryViewCursorMessageID(view domain.StoryView) int {
|
||
if view.PublicForward != nil {
|
||
return view.PublicForward.Message.ID
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func pgStoryPeerCursorKey(peer domain.Peer) int64 {
|
||
if peer.Type == domain.PeerTypeChannel {
|
||
return -peer.ID
|
||
}
|
||
return peer.ID
|
||
}
|
||
|
||
func sortPGStoryViewsForList(views []domain.StoryView, reactionsFirst, forwardsFirst bool) {
|
||
sort.Slice(views, func(i, j int) bool {
|
||
gi := pgStoryViewSortGroup(views[i], reactionsFirst, forwardsFirst)
|
||
gj := pgStoryViewSortGroup(views[j], reactionsFirst, forwardsFirst)
|
||
if gi != gj {
|
||
return gi < gj
|
||
}
|
||
if views[i].Date != views[j].Date {
|
||
return views[i].Date > views[j].Date
|
||
}
|
||
if pgStoryViewCursorKey(views[i]) != pgStoryViewCursorKey(views[j]) {
|
||
return pgStoryViewCursorKey(views[i]) > pgStoryViewCursorKey(views[j])
|
||
}
|
||
return pgStoryViewCursorMessageID(views[i]) > pgStoryViewCursorMessageID(views[j])
|
||
})
|
||
}
|
||
|
||
func encodeStoryReaction(reaction *domain.MessageReaction) ([]byte, error) {
|
||
if reaction == nil {
|
||
return []byte("{}"), nil
|
||
}
|
||
return json.Marshal(reaction)
|
||
}
|
||
|
||
func decodeStoryReaction(raw string) (*domain.MessageReaction, error) {
|
||
if raw == "" || raw == "{}" || raw == "null" {
|
||
return nil, nil
|
||
}
|
||
var reaction domain.MessageReaction
|
||
if err := json.Unmarshal([]byte(raw), &reaction); err != nil {
|
||
return nil, err
|
||
}
|
||
if reaction.Type == "" {
|
||
return nil, nil
|
||
}
|
||
return &reaction, nil
|
||
}
|
||
|
||
func encodePrivacyRules(rules []domain.PrivacyRule) ([]byte, error) {
|
||
if len(rules) == 0 {
|
||
return []byte("[]"), nil
|
||
}
|
||
return json.Marshal(rules)
|
||
}
|
||
|
||
func encodeStoryMediaAreas(areas []domain.StoryMediaArea) ([]byte, error) {
|
||
if len(areas) == 0 {
|
||
return []byte("[]"), nil
|
||
}
|
||
raw, err := json.Marshal(areas)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return raw, nil
|
||
}
|
||
|
||
func decodeStoryMediaAreas(raw string) ([]domain.StoryMediaArea, error) {
|
||
if raw == "" || raw == "[]" || raw == "null" {
|
||
return nil, nil
|
||
}
|
||
var areas []domain.StoryMediaArea
|
||
if err := json.Unmarshal([]byte(raw), &areas); err != nil {
|
||
return nil, err
|
||
}
|
||
return clonePGStoryMediaAreas(areas), nil
|
||
}
|
||
|
||
func encodeStoryForward(forward *domain.StoryForward) ([]byte, error) {
|
||
if forward == nil {
|
||
return []byte("{}"), nil
|
||
}
|
||
raw, err := json.Marshal(forward)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return raw, nil
|
||
}
|
||
|
||
func decodeStoryForward(raw string) (*domain.StoryForward, error) {
|
||
if raw == "" || raw == "{}" || raw == "null" {
|
||
return nil, nil
|
||
}
|
||
var forward domain.StoryForward
|
||
if err := json.Unmarshal([]byte(raw), &forward); err != nil {
|
||
return nil, err
|
||
}
|
||
if forward.From.Type == "" && forward.FromName == "" && forward.StoryID == 0 {
|
||
return nil, nil
|
||
}
|
||
return clonePGStoryForward(&forward), nil
|
||
}
|
||
|
||
func addPGStoryReactionCount(views *domain.StoryViews, reaction domain.MessageReaction) {
|
||
for i := range views.Reactions {
|
||
if views.Reactions[i].Reaction == reaction {
|
||
views.Reactions[i].Count++
|
||
return
|
||
}
|
||
}
|
||
views.Reactions = append(views.Reactions, domain.ChannelMessageReactionCount{
|
||
Reaction: reaction,
|
||
Count: 1,
|
||
ChosenOrder: len(views.Reactions),
|
||
})
|
||
}
|
||
|
||
func samePGReaction(a, b *domain.MessageReaction) bool {
|
||
if a == nil || b == nil {
|
||
return a == nil && b == nil
|
||
}
|
||
return *a == *b
|
||
}
|
||
|
||
func clonePGStory(story domain.Story) domain.Story {
|
||
story.PrivacyRules = clonePGPrivacyRules(story.PrivacyRules)
|
||
story.AllowUserIDs = append([]int64(nil), story.AllowUserIDs...)
|
||
story.DisallowUserIDs = append([]int64(nil), story.DisallowUserIDs...)
|
||
story.Entities = append([]domain.MessageEntity(nil), story.Entities...)
|
||
story.MediaAreas = clonePGStoryMediaAreas(story.MediaAreas)
|
||
story.Forward = clonePGStoryForward(story.Forward)
|
||
story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...)
|
||
story.Views.RecentViewers = append([]int64(nil), story.Views.RecentViewers...)
|
||
story.SentReaction = clonePGReactionPtr(story.SentReaction)
|
||
return story
|
||
}
|
||
|
||
func clonePGStoryForward(in *domain.StoryForward) *domain.StoryForward {
|
||
if in == nil {
|
||
return nil
|
||
}
|
||
out := *in
|
||
return &out
|
||
}
|
||
|
||
func clonePGStoryMediaAreas(in []domain.StoryMediaArea) []domain.StoryMediaArea {
|
||
if len(in) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]domain.StoryMediaArea, len(in))
|
||
for i, area := range in {
|
||
out[i] = area
|
||
out[i].Reaction = clonePGReactionPtr(area.Reaction)
|
||
if area.Geo != nil {
|
||
geo := *area.Geo
|
||
out[i].Geo = &geo
|
||
}
|
||
if area.GeoAddress != nil {
|
||
address := *area.GeoAddress
|
||
out[i].GeoAddress = &address
|
||
}
|
||
if area.Venue != nil {
|
||
venue := *area.Venue
|
||
out[i].Venue = &venue
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func fanoutPGStorySnapshot(story domain.Story) domain.Story {
|
||
story = clonePGStory(story)
|
||
story.Out = false
|
||
story.Views = domain.StoryViews{}
|
||
story.SentReaction = nil
|
||
return story
|
||
}
|
||
|
||
func clonePGPrivacyRules(in []domain.PrivacyRule) []domain.PrivacyRule {
|
||
if len(in) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]domain.PrivacyRule, len(in))
|
||
for i, rule := range in {
|
||
out[i] = rule
|
||
out[i].UserIDs = append([]int64(nil), rule.UserIDs...)
|
||
out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func clonePGReactionPtr(in *domain.MessageReaction) *domain.MessageReaction {
|
||
if in == nil {
|
||
return nil
|
||
}
|
||
out := *in
|
||
return &out
|
||
}
|