Files
pipu-music/services/api/internal/uploads/store.go
T
mrleo1nidandCursor 066ba24834
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
feat: finish the upload, playback, and test gaps from the docs
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:37:53 +03:00

617 lines
23 KiB
Go

package uploads
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound — файла (пачки) нет.
var ErrNotFound = errors.New("uploads: not found")
// ErrWrongStatus — переход из текущего статуса не разрешён (гонка или повторное решение).
var ErrWrongStatus = errors.New("uploads: wrong status")
// Store — таблицы очереди и чтение каталога для проверки дублей.
type Store struct {
pool *pgxpool.Pool
}
// NewStore создаёт Store.
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func newID() uuid.UUID { return uuid.Must(uuid.NewV7()) }
// --- Настройки ---
const settingsKey = "uploads.settings"
// Settings — текущие настройки очереди (по умолчанию, если не сохранены).
func (st *Store) Settings(ctx context.Context) (Settings, error) {
var raw []byte
err := st.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, settingsKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultSettings, nil
}
if err != nil {
return Settings{}, fmt.Errorf("uploads: settings: %w", err)
}
s := DefaultSettings
if err := json.Unmarshal(raw, &s); err != nil {
return Settings{}, fmt.Errorf("uploads: settings: %w", err)
}
return s, nil
}
// SaveSettings сохраняет настройки.
func (st *Store) SaveSettings(ctx context.Context, s Settings) error {
raw, _ := json.Marshal(s)
_, err := st.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, settingsKey, raw)
if err != nil {
return fmt.Errorf("uploads: save settings: %w", err)
}
return nil
}
// --- Квота ---
// occupyingStatuses — для SQL: статусы, занимающие место.
var occupyingStatuses = []string{"uploading", "processing", "pending_review", "approved", "importing", "import_failed"}
// Usage — занято всего и пользователем.
func (st *Store) Usage(ctx context.Context, userID uuid.UUID) (used, userUsed int64, err error) {
err = st.pool.QueryRow(ctx, `
SELECT (SELECT used_bytes FROM upload_quota),
coalesce((SELECT sum(size_bytes) FROM upload_files WHERE author_id = $1 AND status::text = ANY($2)), 0)`,
userID, occupyingStatuses).Scan(&used, &userUsed)
if err != nil {
return 0, 0, fmt.Errorf("uploads: usage: %w", err)
}
return used, userUsed, nil
}
// NewFile — файл, заявленный при создании пачки.
type NewFile struct {
ClientID string
Name string
RelPath *string
Size int64
SHA256 []byte
}
// Reserved — итог по файлу: либо id в очереди, либо код отказа.
type Reserved struct {
ClientID string
FileID uuid.UUID
Rejected string
Track *uuid.UUID // для дубля — с каким треком совпал
}
// CreateBatch создаёт пачку и резервирует место под файлы: проверка «занято + запрошено ≤ лимит»
// под блокировкой строки счётчика (docs/uploads.md#3). Дубли по SHA-256 отклоняются, не занимая места.
func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings) (uuid.UUID, []Reserved, error) {
tx, err := st.pool.Begin(ctx)
if err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: create batch: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var used, userUsed int64
if err := tx.QueryRow(ctx, `SELECT used_bytes FROM upload_quota WHERE id FOR UPDATE`).Scan(&used); err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: lock quota: %w", err)
}
if err := tx.QueryRow(ctx, `
SELECT coalesce(sum(size_bytes), 0) FROM upload_files WHERE author_id = $1 AND status::text = ANY($2)`,
author, occupyingStatuses).Scan(&userUsed); err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: user usage: %w", err)
}
batchID := newID()
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment) VALUES ($1, $2, $3)`,
batchID, author, comment); err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: insert batch: %w", err)
}
out := make([]Reserved, 0, len(files))
seen := map[string]bool{}
var reserved int64
for _, f := range files {
r := Reserved{ClientID: f.ClientID}
key := string(f.SHA256)
switch {
case f.Size > s.MaxFileBytes:
r.Rejected = CodeTooLarge
case seen[key]:
r.Rejected = CodeDuplicate // второй такой же файл в одной пачке
default:
track, dup, err := duplicateBySHA(ctx, tx, f.SHA256, uuid.Nil)
if err != nil {
return uuid.Nil, nil, err
}
switch {
case dup:
r.Rejected, r.Track = CodeDuplicate, track
case used+reserved+f.Size > s.QueueLimitBytes:
r.Rejected = CodeQueueFull
case s.PerUserLimitBytes != nil && userUsed+reserved+f.Size > *s.PerUserLimitBytes:
r.Rejected = CodeUserQuotaFull
}
}
seen[key] = true
if r.Rejected == "" {
r.FileID = newID()
staging := batchID.String() + "/" + r.FileID.String()
if _, err := tx.Exec(ctx, `
INSERT INTO upload_files (id, batch_id, author_id, status, original_name, client_rel_path, size_bytes, sha256,
staging_path)
VALUES ($1, $2, $3, 'uploading', $4, $5, $6, $7, $8)`,
r.FileID, batchID, author, f.Name, f.RelPath, f.Size, f.SHA256, staging); err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: insert file: %w", err)
}
reserved += f.Size
}
out = append(out, r)
}
if _, err := tx.Exec(ctx, `UPDATE upload_quota SET used_bytes = used_bytes + $1 WHERE id`, reserved); err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: reserve: %w", err)
}
return batchID, out, tx.Commit(ctx)
}
// transition меняет статус под блокировкой строки и освобождает место, если новый статус его не занимает.
func transition(ctx context.Context, tx pgx.Tx, id uuid.UUID, from []Status, to Status) (Status, error) {
var (
prev Status
size int64
)
err := tx.QueryRow(ctx, `SELECT status, size_bytes FROM upload_files WHERE id = $1 FOR UPDATE`, id).Scan(&prev, &size)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("uploads: lock file: %w", err)
}
ok := false
for _, f := range from {
ok = ok || f == prev
}
if !ok {
return prev, ErrWrongStatus
}
if _, err := tx.Exec(ctx, `UPDATE upload_files SET status = $2, updated_at = now() WHERE id = $1`, id, to); err != nil {
return "", fmt.Errorf("uploads: set status: %w", err)
}
if prev.Occupies() && !to.Occupies() {
if _, err := tx.Exec(ctx, `UPDATE upload_quota SET used_bytes = greatest(0, used_bytes - $1) WHERE id`, size); err != nil {
return "", fmt.Errorf("uploads: release quota: %w", err)
}
}
return prev, nil
}
// Transition — смена статуса с дополнительным обновлением полей в той же транзакции (extra может быть nil).
func (st *Store) Transition(ctx context.Context, id uuid.UUID, from []Status, to Status, extra func(tx pgx.Tx) error) error {
tx, err := st.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("uploads: transition: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := transition(ctx, tx, id, from, to); err != nil {
return err
}
if extra != nil {
if err := extra(tx); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// SetReceived — смещение tus после принятого куска.
func (st *Store) SetReceived(ctx context.Context, id uuid.UUID, offset int64) error {
_, err := st.pool.Exec(ctx, `
UPDATE upload_files SET received_bytes = $2, updated_at = now() WHERE id = $1 AND status = 'uploading'`, id, offset)
if err != nil {
return fmt.Errorf("uploads: set received: %w", err)
}
return nil
}
// --- Чтение ---
const fileColumns = `f.id, f.batch_id, f.author_id, u.username, b.comment, f.status, f.original_name, f.client_rel_path,
f.size_bytes, f.received_bytes, f.sha256, coalesce(f.staging_path, ''), f.format, f.codec, f.bitrate, f.sample_rate,
f.bit_depth, f.duration_ms, f.tags, f.metadata, f.metadata_edited, f.matched_artist_id, f.matched_album_id,
f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.quality_upgrade, f.target_source_id, f.target_rel_path,
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, b.cover_path, f.created_at, f.updated_at`
const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id`
func scanFile(row pgx.Row) (File, error) {
var (
f File
tags, meta []byte
)
err := row.Scan(&f.ID, &f.BatchID, &f.AuthorID, &f.AuthorName, &f.BatchComment, &f.Status, &f.OriginalName,
&f.ClientRelPath, &f.Size, &f.Received, &f.SHA256, &f.StagingPath, &f.Format, &f.Codec, &f.Bitrate, &f.SampleRate,
&f.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID,
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath,
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return File{}, ErrNotFound
}
if err != nil {
return File{}, fmt.Errorf("uploads: scan file: %w", err)
}
if len(tags) > 0 {
_ = json.Unmarshal(tags, &f.Tags)
}
if len(meta) > 0 {
var m Meta
if json.Unmarshal(meta, &m) == nil {
f.Meta = &m
}
}
return f, nil
}
// File — файл по id.
func (st *Store) File(ctx context.Context, id uuid.UUID) (File, error) {
return scanFile(st.pool.QueryRow(ctx, `SELECT `+fileColumns+fileFrom+` WHERE f.id = $1`, id))
}
// ListFilter — фильтры списка очереди.
type ListFilter struct {
AuthorID *uuid.UUID
Statuses []Status
PossibleDuplicate *bool
BatchID *uuid.UUID
Cursor string // "<unix-nano>|<id>" последнего элемента
Limit int
}
// List — файлы очереди, новые первыми, страницами.
func (st *Store) List(ctx context.Context, fl ListFilter) ([]File, string, error) {
where := []string{"true"}
args := []any{}
add := func(cond string, v any) {
args = append(args, v)
where = append(where, strings.ReplaceAll(cond, "?", fmt.Sprintf("$%d", len(args))))
}
if fl.AuthorID != nil {
add("f.author_id = ?", *fl.AuthorID)
}
if fl.BatchID != nil {
add("f.batch_id = ?", *fl.BatchID)
}
if len(fl.Statuses) > 0 {
ss := make([]string, len(fl.Statuses))
for i, s := range fl.Statuses {
ss[i] = string(s)
}
add("f.status::text = ANY(?)", ss)
}
if fl.PossibleDuplicate != nil {
add("f.possible_duplicate = ?", *fl.PossibleDuplicate)
}
if fl.Cursor != "" {
ts, id, ok := strings.Cut(fl.Cursor, "|")
var nano int64
cid, err := uuid.Parse(id)
if _, serr := fmt.Sscan(ts, &nano); !ok || err != nil || serr != nil {
return nil, "", errors.New("uploads: bad cursor")
}
args = append(args, time.Unix(0, nano).UTC(), cid)
where = append(where, fmt.Sprintf("(f.created_at, f.id) < ($%d, $%d)", len(args)-1, len(args)))
}
limit := min(max(fl.Limit, 1), 200)
args = append(args, limit+1)
rows, err := st.pool.Query(ctx, `SELECT `+fileColumns+fileFrom+` WHERE `+strings.Join(where, " AND ")+
fmt.Sprintf(` ORDER BY f.created_at DESC, f.id DESC LIMIT $%d`, len(args)), args...)
if err != nil {
return nil, "", fmt.Errorf("uploads: list: %w", err)
}
defer rows.Close()
var out []File
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return nil, "", err
}
out = append(out, f)
}
if err := rows.Err(); err != nil {
return nil, "", err
}
next := ""
if len(out) > limit {
out = out[:limit]
last := out[limit-1]
next = fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID)
}
return out, next, nil
}
// NextWithStatus — самый старый файл в статусе (для фоновых обработчиков).
func (st *Store) NextWithStatus(ctx context.Context, s Status) (File, error) {
return scanFile(st.pool.QueryRow(ctx, `SELECT `+fileColumns+fileFrom+
` WHERE f.status = $1 ORDER BY f.updated_at, f.id LIMIT 1`, s))
}
// StagingInUse — пути staging файлов, которым он ещё нужен (сверка при старте).
func (st *Store) StagingInUse(ctx context.Context) (map[string]bool, error) {
rows, err := st.pool.Query(ctx, `SELECT staging_path FROM upload_files WHERE status::text = ANY($1) AND staging_path IS NOT NULL`,
occupyingStatuses)
if err != nil {
return nil, fmt.Errorf("uploads: staging in use: %w", err)
}
paths, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return nil, err
}
out := make(map[string]bool, len(paths))
for _, p := range paths {
out[p] = true
}
return out, nil
}
// Stale — недокачанные файлы старше before.
func (st *Store) Stale(ctx context.Context, before time.Time) ([]uuid.UUID, error) {
rows, err := st.pool.Query(ctx, `SELECT id FROM upload_files WHERE status = 'uploading' AND updated_at < $1`, before)
if err != nil {
return nil, fmt.Errorf("uploads: stale: %w", err)
}
return pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
}
// Audit пишет действие в журнал.
func (st *Store) Audit(ctx context.Context, actor *uuid.UUID, action string, target uuid.UUID, payload map[string]any) {
raw, _ := json.Marshal(payload)
_, _ = st.pool.Exec(ctx, `
INSERT INTO audit_log (id, actor_id, action, target_type, target_id, payload) VALUES ($1, $2, $3, 'upload_file', $4, $5)`,
newID(), actor, action, target, raw)
}
// --- Каталог: дубли, сопоставление ---
// duplicateBySHA — точная копия: файл библиотеки или другой файл очереди, ещё занимающий место.
func duplicateBySHA(ctx context.Context, q interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}, sum []byte, exclude uuid.UUID) (*uuid.UUID, bool, error) {
if len(sum) == 0 {
return nil, false, nil
}
var track *uuid.UUID
err := q.QueryRow(ctx, `
SELECT track_id FROM media_files WHERE sha256 = $1 AND missing_since IS NULL LIMIT 1`, sum).Scan(&track)
if err == nil {
return track, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, false, fmt.Errorf("uploads: sha duplicate: %w", err)
}
var id uuid.UUID
err = q.QueryRow(ctx, `
SELECT id FROM upload_files WHERE sha256 = $1 AND id <> $2 AND status::text = ANY($3) LIMIT 1`,
sum, exclude, occupyingStatuses).Scan(&id)
if err == nil {
return nil, true, nil
}
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
return nil, false, fmt.Errorf("uploads: sha duplicate: %w", err)
}
// DuplicateBySHA — см. duplicateBySHA.
func (st *Store) DuplicateBySHA(ctx context.Context, sum []byte, exclude uuid.UUID) (*uuid.UUID, bool, error) {
return duplicateBySHA(ctx, st.pool, sum, exclude)
}
// PrintCandidate — отпечаток для сравнения: трек библиотеки или другой файл очереди.
type PrintCandidate struct {
TrackID *uuid.UUID
Points []uint32
}
// FingerprintCandidates — отпечатки библиотеки и очереди с длительностью в пределах tolMs.
func (st *Store) FingerprintCandidates(ctx context.Context, durationMs, tolMs int, exclude uuid.UUID) ([]PrintCandidate, error) {
rows, err := st.pool.Query(ctx, `
SELECT mf.track_id, tf.fingerprint FROM track_fingerprints tf JOIN media_files mf ON mf.id = tf.file_id
WHERE tf.duration_ms BETWEEN $1 AND $2 AND mf.missing_since IS NULL
UNION ALL
SELECT NULL, fingerprint FROM upload_files
WHERE fingerprint IS NOT NULL AND id <> $3 AND status::text = ANY($4) AND duration_ms BETWEEN $1 AND $2`,
durationMs-tolMs, durationMs+tolMs, exclude, occupyingStatuses)
if err != nil {
return nil, fmt.Errorf("uploads: fingerprint candidates: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (PrintCandidate, error) {
var (
c PrintCandidate
raw []int32
)
if err := r.Scan(&c.TrackID, &raw); err != nil {
return c, err
}
c.Points = make([]uint32, len(raw))
for i, v := range raw {
c.Points[i] = uint32(v)
}
return c, nil
})
}
// MetadataMatch — трек библиотеки с тем же исполнителем и названием и длительностью ±tolMs.
func (st *Store) MetadataMatch(ctx context.Context, artistKey, titleKey string, durationMs, tolMs int) (*uuid.UUID, error) {
var id uuid.UUID
err := st.pool.QueryRow(ctx, `
SELECT t.id FROM tracks t
JOIN track_artists ta ON ta.track_id = t.id
JOIN artists a ON a.id = ta.artist_id
WHERE t.title_norm = $1 AND a.name_norm = $2 AND abs(t.duration_ms - $3) <= $4
LIMIT 1`, titleKey, artistKey, durationMs, tolMs).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("uploads: metadata match: %w", err)
}
return &id, nil
}
// MatchCatalog — существующие исполнитель (по ключу имени) и его альбом (по ключу названия), если есть.
func (st *Store) MatchCatalog(ctx context.Context, artistKey, albumKey string) (artist, album *uuid.UUID, maxDisc int, err error) {
var a uuid.UUID
err = st.pool.QueryRow(ctx, `SELECT id FROM artists WHERE name_norm = $1`, artistKey).Scan(&a)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, 0, nil
}
if err != nil {
return nil, nil, 0, fmt.Errorf("uploads: match artist: %w", err)
}
artist = &a
var (
al uuid.UUID
disc *int
)
err = st.pool.QueryRow(ctx, `
SELECT al.id, (SELECT max(disc_no) FROM tracks WHERE album_id = al.id)
FROM albums al WHERE al.album_artist_id = $1 AND al.title_norm = $2`, a, albumKey).Scan(&al, &disc)
if errors.Is(err, pgx.ErrNoRows) {
return artist, nil, 0, nil
}
if err != nil {
return nil, nil, 0, fmt.Errorf("uploads: match album: %w", err)
}
if disc != nil {
maxDisc = *disc
}
return artist, &al, maxDisc, nil
}
// BatchMaxDisc — наибольший номер диска среди файлов пачки с тем же альбомом (для {disc:1-}).
func (st *Store) BatchMaxDisc(ctx context.Context, batchID uuid.UUID, album string) (int, error) {
var n *int
err := st.pool.QueryRow(ctx, `
SELECT max((metadata->>'disc')::int) FROM upload_files
WHERE batch_id = $1 AND metadata->>'album' = $2 AND status::text = ANY($3)`,
batchID, album, occupyingStatuses).Scan(&n)
if err != nil {
return 0, fmt.Errorf("uploads: batch discs: %w", err)
}
if n == nil {
return 0, nil
}
return *n, nil
}
// MarkImported связывает файл библиотеки с загрузкой.
func MarkImported(ctx context.Context, tx pgx.Tx, uploadID, mediaFileID uuid.UUID) error {
if _, err := tx.Exec(ctx, `UPDATE upload_files SET imported_file_id = $2, status_reason = NULL WHERE id = $1`,
uploadID, mediaFileID); err != nil {
return fmt.Errorf("uploads: mark imported: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE media_files SET upload_file_id = $1 WHERE id = $2`, uploadID, mediaFileID); err != nil {
return fmt.Errorf("uploads: link media file: %w", err)
}
return nil
}
// ReconcileQuota пересчитывает занятое место по статусам файлов.
func (st *Store) ReconcileQuota(ctx context.Context) error {
_, err := st.pool.Exec(ctx, `
UPDATE upload_quota SET used_bytes = coalesce((
SELECT sum(size_bytes) FROM upload_files WHERE status::text = ANY($1)
), 0)`, occupyingStatuses)
if err != nil {
return fmt.Errorf("uploads: reconcile quota: %w", err)
}
return nil
}
// BestAudio — лучший доступный файл трека (для сравнения качества).
func (st *Store) BestAudio(ctx context.Context, trackID uuid.UUID) (codec, format string, bitrate int, err error) {
err = st.pool.QueryRow(ctx, `
SELECT coalesce(codec, ''), coalesce(format, ''), coalesce(bitrate, 0)
FROM media_files
WHERE track_id = $1 AND missing_since IS NULL
ORDER BY (codec IN ('flac', 'alac', 'wavpack', 'ape', 'tta') OR codec LIKE 'pcm_%') DESC,
bitrate DESC NULLS LAST
LIMIT 1`, trackID).Scan(&codec, &format, &bitrate)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", 0, nil
}
if err != nil {
return "", "", 0, fmt.Errorf("uploads: best audio: %w", err)
}
return codec, format, bitrate, nil
}
// PendingInBatch — файлы пачки, которые ещё можно править.
func (st *Store) PendingInBatch(ctx context.Context, batchID uuid.UUID) ([]File, error) {
rows, err := st.pool.Query(ctx, `SELECT `+fileColumns+fileFrom+`
WHERE f.batch_id = $1 AND f.status IN ('pending_review', 'import_failed')
ORDER BY f.created_at, f.id`, batchID)
if err != nil {
return nil, fmt.Errorf("uploads: batch files: %w", err)
}
defer rows.Close()
var out []File
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// SetCover записывает путь обложки пачки (nil — снять).
func (st *Store) SetCover(ctx context.Context, batchID uuid.UUID, path *string) error {
tag, err := st.pool.Exec(ctx, `UPDATE upload_batches SET cover_path = $2 WHERE id = $1`, batchID, path)
if err != nil {
return fmt.Errorf("uploads: set cover: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// UnseenDecisions — решения по файлам автора новее последнего визита.
func (st *Store) UnseenDecisions(ctx context.Context, userID uuid.UUID) (int, error) {
var n int
err := st.pool.QueryRow(ctx, `
SELECT count(*) FROM upload_files f
LEFT JOIN upload_visits v ON v.user_id = f.author_id
WHERE f.author_id = $1
AND f.status IN ('imported', 'rejected', 'duplicate', 'invalid', 'expired', 'import_failed')
AND f.updated_at > coalesce(v.seen_at, '-infinity')`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("uploads: unseen: %w", err)
}
return n, nil
}
// MarkSeen отмечает визит автора.
func (st *Store) MarkSeen(ctx context.Context, userID uuid.UUID, at time.Time) error {
_, err := st.pool.Exec(ctx, `
INSERT INTO upload_visits (user_id, seen_at) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET seen_at = EXCLUDED.seen_at`, userID, at)
if err != nil {
return fmt.Errorf("uploads: mark seen: %w", err)
}
return nil
}