fix: hide suspended account
This commit is contained in:
@@ -34,6 +34,8 @@ func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, of
|
||||
END AS rank
|
||||
FROM users u
|
||||
WHERE u.id <> $1
|
||||
-- A suspended account can't be @mentioned into a post.
|
||||
AND u.banned_at IS NULL
|
||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
||||
ORDER BY rank, u.name, u.id
|
||||
LIMIT $4 OFFSET $5`,
|
||||
|
||||
@@ -345,7 +345,8 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
||||
// enough at our user-table scale; revisit alongside the pg_trgm switch.
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`,
|
||||
`SELECT COUNT(*) FROM users
|
||||
WHERE (name ILIKE $1 OR handle::text ILIKE $1) AND banned_at IS NULL`,
|
||||
substring).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count search users: %w", err)
|
||||
}
|
||||
@@ -358,7 +359,13 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
||||
// and name could reshuffle between pages.
|
||||
sql := `SELECT ` + userColumns + `
|
||||
FROM users
|
||||
WHERE name ILIKE $1 OR handle::text ILIKE $1
|
||||
WHERE (name ILIKE $1 OR handle::text ILIKE $1)
|
||||
-- Suspended accounts are unsearchable. This one query backs the
|
||||
-- people search page, the mention autocomplete AND both chat member
|
||||
-- pickers, so a banned account appearing here is offered as someone
|
||||
-- to @mention or add to a group. ResolveHandles already refuses them
|
||||
-- on the way in, which without this makes "Add" silently do nothing.
|
||||
AND banned_at IS NULL
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN handle::text ILIKE $2 THEN 0
|
||||
@@ -455,6 +462,10 @@ func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since ti
|
||||
FROM users u
|
||||
WHERE u.created_at >= $2
|
||||
AND u.id <> $1
|
||||
-- Suspended accounts are not discoverable. A ban hides someone from
|
||||
-- the app; surfacing them in the welcome strip would be the most
|
||||
-- prominent placement on the home page.
|
||||
AND u.banned_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM follows f
|
||||
WHERE f.follower_id = $1 AND f.following_id = u.id
|
||||
|
||||
@@ -16,6 +16,13 @@ import (
|
||||
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
|
||||
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
|
||||
// verses / prose_chapters / transcriptions collections).
|
||||
var (
|
||||
// ErrVaniRefInvalid is a malformed content id.
|
||||
ErrVaniRefInvalid = errors.New("not a valid content reference")
|
||||
// ErrVaniRefNotFound is a well-formed id that matches nothing.
|
||||
ErrVaniRefNotFound = errors.New("that content no longer exists")
|
||||
)
|
||||
|
||||
type VaniRepo struct {
|
||||
pg *Postgres
|
||||
mongo *MongoDB
|
||||
@@ -87,6 +94,64 @@ func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upsert writes a day's pick, REPLACING any existing one.
|
||||
//
|
||||
// Distinct from Insert, which is ON CONFLICT DO NOTHING because the automatic
|
||||
// picker must never overwrite a day already decided. An admin choosing the day
|
||||
// deliberately is the one case that should win.
|
||||
func (r *VaniRepo) Upsert(ctx context.Context, pick *model.VaniPick) error {
|
||||
b, err := json.Marshal(pick.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
ON CONFLICT (day) DO UPDATE
|
||||
SET kind = EXCLUDED.kind,
|
||||
ref_id = EXCLUDED.ref_id,
|
||||
payload = EXCLUDED.payload`,
|
||||
pick.Day, pick.Kind, pick.RefID, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert vani pick: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear removes a day's pick, so the automatic picker chooses again.
|
||||
func (r *VaniRepo) Clear(ctx context.Context, day time.Time) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `DELETE FROM vani_of_the_day WHERE day = $1`, day)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear vani pick: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PicksFrom lists decided days from `from` onward, for the admin schedule.
|
||||
func (r *VaniRepo) PicksFrom(ctx context.Context, from time.Time, limit int) ([]model.VaniPick, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT day, kind, ref_id, payload FROM vani_of_the_day
|
||||
WHERE day >= $1 ORDER BY day LIMIT $2`, from, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list vani picks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.VaniPick{}
|
||||
for rows.Next() {
|
||||
var p model.VaniPick
|
||||
var raw []byte
|
||||
if err := rows.Scan(&p.Day, &p.Kind, &p.RefID, &raw); err != nil {
|
||||
return nil, fmt.Errorf("scan vani pick: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p.Payload); err != nil {
|
||||
return nil, fmt.Errorf("decode vani payload: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// BookTitle resolves a book code to its display title (from book_meta.data.title),
|
||||
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
|
||||
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
|
||||
@@ -135,6 +200,50 @@ func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out
|
||||
return false, cur.Err()
|
||||
}
|
||||
|
||||
// oneByHex loads a single document by its _id hex. Shared by the three
|
||||
// admin-pick lookups, which differ only in collection and target type.
|
||||
func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface{}) error {
|
||||
oid, err := bson.ObjectIDFromHex(hex)
|
||||
if err != nil {
|
||||
return ErrVaniRefInvalid
|
||||
}
|
||||
err = r.mongo.Collection(coll).FindOne(ctx, bson.D{{Key: "_id", Value: oid}}).Decode(out)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return ErrVaniRefNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load %s %s: %w", coll, hex, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
|
||||
// chosen. The payload is then built by the same builders the random picker
|
||||
// uses, so a pinned pick renders identically to an automatic one.
|
||||
func (r *VaniRepo) VerseByHex(ctx context.Context, hex string) (*VerseDoc, error) {
|
||||
var v VerseDoc
|
||||
if err := r.oneByHex(ctx, "verses", hex, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (r *VaniRepo) ProseByHex(ctx context.Context, hex string) (*ProseChapterDoc, error) {
|
||||
var p ProseChapterDoc
|
||||
if err := r.oneByHex(ctx, "prose_chapters", hex, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (r *VaniRepo) TranscriptByHex(ctx context.Context, hex string) (*TranscriptionDoc, error) {
|
||||
var t TranscriptionDoc
|
||||
if err := r.oneByHex(ctx, "transcriptions", hex, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// SampleVerse returns a random verse with a translation, excluding the given
|
||||
// _id hexes. Returns nil when nothing matches.
|
||||
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
|
||||
|
||||
Reference in New Issue
Block a user