feat: add growth tracking page and related functionality
- Implemented a new Growth page to track practice time and trends. - Added API integration for fetching review data. - Created components for displaying practice statistics and trends. - Updated navigation titles for the main index and growth pages. - Removed unused styles from the index page. - Introduced a Projects management page for adding and editing practice projects. - Developed a Record form for logging practice sessions with validation. - Added utility functions for date manipulation and duration formatting. - Implemented error handling and session management in the practice service. - Created unit tests for the practice service to ensure reliability.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
package practice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type RecordInput struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
Date string `json:"date"`
|
||||
Minutes int `json:"minutes"`
|
||||
Note string `json:"note"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
updatedAt time.Time
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
ProjectName string `json:"projectName"`
|
||||
Archived bool `json:"archived"`
|
||||
Date string `json:"date"`
|
||||
Minutes int `json:"minutes"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type RecordList struct {
|
||||
Records []Record `json:"records"`
|
||||
NextCursor string `json:"nextCursor"`
|
||||
}
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{16,128}$`)
|
||||
var objectIDPattern = regexp.MustCompile(`^[A-Z2-7]{26}$`)
|
||||
|
||||
func validDate(date string) bool {
|
||||
t, err := time.Parse(time.DateOnly, date)
|
||||
return err == nil && t.Year() >= 1 && t.Format(time.DateOnly) == date
|
||||
}
|
||||
|
||||
const recordSelect = `SELECT r.id,r.project_id,p.name,p.archived,r.practice_date::text,r.minutes,r.note,r.updated_at
|
||||
FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id`
|
||||
|
||||
func scanRecord(row pgx.Row) (Record, error) {
|
||||
var r Record
|
||||
err := row.Scan(&r.ID, &r.ProjectID, &r.ProjectName, &r.Archived, &r.Date, &r.Minutes, &r.Note, &r.updatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return r, ErrUnavailable
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (s *Store) Record(ctx context.Context, user, id string) (Record, error) {
|
||||
return scanRecord(s.db.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id))
|
||||
}
|
||||
|
||||
// Records bounds every response. The updated-at/id cursor permits complete day lists
|
||||
// without silently truncating them or loading an account's entire history.
|
||||
// Recent entries include just-saved corrections and backdated practices first.
|
||||
func (s *Store) Records(ctx context.Context, user, from, to, cursor string, limit int) (RecordList, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return RecordList{}, ErrInvalid
|
||||
}
|
||||
if from == "" {
|
||||
from = "0001-01-01"
|
||||
}
|
||||
if to == "" {
|
||||
to = s.Today()
|
||||
}
|
||||
if !validDate(from) || !validDate(to) || from > to {
|
||||
return RecordList{}, ErrInvalid
|
||||
}
|
||||
cursorTime, cursorID := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), "~"
|
||||
if cursor != "" {
|
||||
parts := strings.Split(cursor, "~")
|
||||
if len(parts) != 2 || !objectIDPattern.MatchString(parts[1]) {
|
||||
return RecordList{}, ErrInvalid
|
||||
}
|
||||
var err error
|
||||
cursorTime, err = time.Parse(time.RFC3339Nano, parts[0])
|
||||
if err != nil {
|
||||
return RecordList{}, ErrInvalid
|
||||
}
|
||||
cursorID = parts[1]
|
||||
}
|
||||
rows, err := s.db.Query(ctx, recordSelect+` WHERE r.user_id=$1 AND r.practice_date BETWEEN $2::date AND $3::date
|
||||
AND (r.updated_at,r.id)<($4,$5) ORDER BY r.updated_at DESC,r.id DESC LIMIT $6`, user, from, to, cursorTime, cursorID, limit+1)
|
||||
if err != nil {
|
||||
return RecordList{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := RecordList{Records: []Record{}}
|
||||
for rows.Next() {
|
||||
r, err := scanRecord(rows)
|
||||
if err != nil {
|
||||
return RecordList{}, err
|
||||
}
|
||||
result.Records = append(result.Records, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return RecordList{}, err
|
||||
}
|
||||
if len(result.Records) > limit {
|
||||
result.Records = result.Records[:limit]
|
||||
last := result.Records[limit-1]
|
||||
result.NextCursor = last.updatedAt.UTC().Format(time.RFC3339Nano) + "~" + last.ID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveRecord commits the record and submission receipt together. Retrying an
|
||||
// uncertain create keeps its ID; a different payload using the same key is rejected.
|
||||
func (s *Store) SaveRecord(ctx context.Context, user, id string, input RecordInput) (Record, error) {
|
||||
if !validDate(input.Date) || input.Date > s.Today() || input.Minutes < 1 || input.Minutes > MinuteLimit ||
|
||||
input.ProjectID == "" || utf8.RuneCountInString(input.Note) > NoteLimit || strings.ContainsRune(input.Note, 0) {
|
||||
return Record{}, ErrInvalid
|
||||
}
|
||||
if id == "" && !requestIDPattern.MatchString(input.RequestID) {
|
||||
return Record{}, ErrInvalid
|
||||
}
|
||||
var result Record
|
||||
err := s.write(ctx, user, func(tx pgx.Tx) error {
|
||||
payload := input
|
||||
payload.RequestID = ""
|
||||
encoded, _ := json.Marshal(payload)
|
||||
hash := digest(string(encoded))
|
||||
if id == "" {
|
||||
var savedHash, savedID string
|
||||
err := tx.QueryRow(ctx, "SELECT payload_hash,record_id FROM submissions WHERE user_id=$1 AND request_id=$2", user, input.RequestID).Scan(&savedHash, &savedID)
|
||||
if err == nil {
|
||||
if savedHash != hash {
|
||||
return ErrConflict
|
||||
}
|
||||
result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, savedID))
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
return ErrDeleted
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
originalProject := ""
|
||||
if id != "" {
|
||||
err := tx.QueryRow(ctx, "SELECT project_id FROM records WHERE user_id=$1 AND id=$2", user, id).Scan(&originalProject)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var archived bool
|
||||
err := tx.QueryRow(ctx, "SELECT archived FROM projects WHERE user_id=$1 AND id=$2", user, input.ProjectID).Scan(&archived)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if archived && originalProject != input.ProjectID {
|
||||
return ErrArchived
|
||||
}
|
||||
if id == "" {
|
||||
id = newID()
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO records(id,user_id,project_id,practice_date,minutes,note)
|
||||
VALUES($1,$2,$3,$4::date,$5,$6)`, id, user, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, "INSERT INTO submissions(user_id,request_id,payload_hash,record_id) VALUES($1,$2,$3,$4)", user, input.RequestID, hash, id); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err = tx.Exec(ctx, `UPDATE records SET project_id=$3,practice_date=$4::date,minutes=$5,note=$6,updated_at=now()
|
||||
WHERE user_id=$1 AND id=$2`, user, id, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id))
|
||||
return err
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DeleteRecord is scoped to the session owner and safe to retry, including after
|
||||
// the last record of a date has gone. Submission receipts deliberately remain.
|
||||
func (s *Store) DeleteRecord(ctx context.Context, user, id string) error {
|
||||
return s.write(ctx, user, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, "DELETE FROM records WHERE user_id=$1 AND id=$2", user, id)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package practice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProjectMinutes struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
Name string `json:"name"`
|
||||
Archived bool `json:"archived"`
|
||||
Minutes int64 `json:"minutes"`
|
||||
}
|
||||
type Summary struct {
|
||||
TotalMinutes int64 `json:"totalMinutes"`
|
||||
PracticeDays int `json:"practiceDays"`
|
||||
RecordCount int64 `json:"recordCount"`
|
||||
Projects []ProjectMinutes `json:"projects"`
|
||||
}
|
||||
type DayMinutes struct {
|
||||
Date string `json:"date"`
|
||||
Minutes int64 `json:"minutes"`
|
||||
}
|
||||
type Review struct {
|
||||
Today string `json:"today"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
AllTime Summary `json:"allTime"`
|
||||
Period Summary `json:"period"`
|
||||
Days []DayMinutes `json:"days"`
|
||||
}
|
||||
type summaryBuilder struct {
|
||||
summary Summary
|
||||
dates map[string]bool
|
||||
projects map[string]ProjectMinutes
|
||||
}
|
||||
|
||||
func newSummary() *summaryBuilder {
|
||||
return &summaryBuilder{dates: map[string]bool{}, projects: map[string]ProjectMinutes{}}
|
||||
}
|
||||
func (b *summaryBuilder) add(date string, project ProjectMinutes, count int64) {
|
||||
b.summary.TotalMinutes += project.Minutes
|
||||
b.summary.RecordCount += count
|
||||
b.dates[date] = true
|
||||
previous := b.projects[project.ProjectID]
|
||||
project.Minutes += previous.Minutes
|
||||
b.projects[project.ProjectID] = project
|
||||
}
|
||||
func (b *summaryBuilder) result() Summary {
|
||||
b.summary.PracticeDays = len(b.dates)
|
||||
b.summary.Projects = []ProjectMinutes{}
|
||||
for _, p := range b.projects {
|
||||
b.summary.Projects = append(b.summary.Projects, p)
|
||||
}
|
||||
sort.Slice(b.summary.Projects, func(i, j int) bool {
|
||||
a, c := b.summary.Projects[i], b.summary.Projects[j]
|
||||
if a.Minutes == c.Minutes {
|
||||
return a.ProjectID < c.ProjectID
|
||||
}
|
||||
return a.Minutes > c.Minutes
|
||||
})
|
||||
return b.summary
|
||||
}
|
||||
|
||||
// Review derives totals, daily minutes and project shares from one SQL snapshot.
|
||||
// Archived projects participate and always use their current name. Monday starts a week.
|
||||
func (s *Store) Review(ctx context.Context, user, period, date string) (Review, error) {
|
||||
if date == "" {
|
||||
date = s.Today()
|
||||
}
|
||||
if !validDate(date) || date > s.Today() {
|
||||
return Review{}, ErrInvalid
|
||||
}
|
||||
anchor, _ := time.Parse(time.DateOnly, date)
|
||||
var start, end time.Time
|
||||
switch period {
|
||||
case "week":
|
||||
start = anchor.AddDate(0, 0, -(int(anchor.Weekday())+6)%7)
|
||||
end = start.AddDate(0, 0, 6)
|
||||
case "month":
|
||||
start = time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
end = start.AddDate(0, 1, -1)
|
||||
default:
|
||||
return Review{}, ErrInvalid
|
||||
}
|
||||
result := Review{Today: s.Today(), From: start.Format(time.DateOnly), To: end.Format(time.DateOnly), Days: []DayMinutes{}}
|
||||
// A single grouped query keeps all-time and selected-period summaries consistent
|
||||
// during concurrent edits, without storing a second set of cumulative balances.
|
||||
rows, err := s.db.Query(ctx, `SELECT r.practice_date::text,p.id,p.name,p.archived,sum(r.minutes)::bigint,count(*)
|
||||
FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id
|
||||
WHERE r.user_id=$1 GROUP BY r.practice_date,p.id,p.name,p.archived`, user)
|
||||
if err != nil {
|
||||
return Review{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
all, selected := newSummary(), newSummary()
|
||||
daily := map[string]int64{}
|
||||
for rows.Next() {
|
||||
var date string
|
||||
var p ProjectMinutes
|
||||
var count int64
|
||||
if err := rows.Scan(&date, &p.ProjectID, &p.Name, &p.Archived, &p.Minutes, &count); err != nil {
|
||||
return Review{}, err
|
||||
}
|
||||
all.add(date, p, count)
|
||||
if date >= result.From && date <= result.To {
|
||||
selected.add(date, p, count)
|
||||
daily[date] += p.Minutes
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Review{}, err
|
||||
}
|
||||
result.AllTime = all.result()
|
||||
result.Period = selected.result()
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
date := d.Format(time.DateOnly)
|
||||
result.Days = append(result.Days, DayMinutes{Date: date, Minutes: daily[date]})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package practice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const NameLimit = 40
|
||||
const NoteLimit = 2000
|
||||
const MinuteLimit = 2147483647 // PostgreSQL integer storage limit; minutes remain exact.
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("输入无效,请检查后重试")
|
||||
ErrUnavailable = errors.New("项目或记录不可用,请刷新后重试")
|
||||
ErrUnauthorized = errors.New("登录已失效,请重新登录")
|
||||
ErrArchived = errors.New("该项目已移出常用列表,请选择其他项目")
|
||||
ErrConflict = errors.New("这次提交已保存了不同内容,请刷新记录后更正")
|
||||
ErrDeleted = errors.New("这次提交的记录已被删除,不会重复创建")
|
||||
)
|
||||
|
||||
var presets = []string{"零基础", "基础提升", "软开素质", "足髋训练", "核心臀腿", "小球核心", "天鹅臂颈", "呼吸训练"}
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New keeps all ownership, dates, archival and persistence rules behind business operations.
|
||||
// now is injectable so business-day boundaries can be verified without changing the host clock.
|
||||
func New(db *pgxpool.Pool, now func() time.Time) *Store {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Store{db: db, now: now}
|
||||
}
|
||||
|
||||
func newID() string { return rand.Text() }
|
||||
func digest(s string) string { sum := sha256.Sum256([]byte(s)); return hex.EncodeToString(sum[:]) }
|
||||
|
||||
// Today is a calendar date in the fixed first-release business timezone, not the device timezone.
|
||||
func (s *Store) Today() string {
|
||||
return s.now().In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.DateOnly)
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// Login accepts only a server-verified, app-scoped WeChat identity. User creation,
|
||||
// presets and the session commit together; deleted presets never reappear on login.
|
||||
func (s *Store) Login(ctx context.Context, identity string) (Session, error) {
|
||||
if identity == "" {
|
||||
return Session{}, ErrUnauthorized
|
||||
}
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
id := newID()
|
||||
result, err := tx.Exec(ctx, "INSERT INTO users(id,wechat_identity) VALUES($1,$2) ON CONFLICT(wechat_identity) DO NOTHING", id, identity)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if result.RowsAffected() == 1 {
|
||||
for _, name := range presets {
|
||||
if _, err = tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", newID(), id, name); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
}
|
||||
} else if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE wechat_identity=$1", identity).Scan(&id); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
// Serialize session maintenance for this user as well as business writes.
|
||||
if _, err = tx.Exec(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", id); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, "DELETE FROM sessions WHERE user_id=$1 AND expires_at <= $2", id, s.now()); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
session := Session{Token: newID() + newID(), ExpiresAt: s.now().Add(30 * 24 * time.Hour)}
|
||||
if _, err = tx.Exec(ctx, "INSERT INTO sessions(token_hash,user_id,expires_at) VALUES($1,$2,$3)", digest(session.Token), id, session.ExpiresAt); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Authenticate never accepts a client-provided user ID. Only opaque, unexpired
|
||||
// bearer tokens are usable; their hashes, not tokens, are kept in PostgreSQL.
|
||||
func (s *Store) Authenticate(ctx context.Context, token string) (string, error) {
|
||||
if len(token) != 52 {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
var user string
|
||||
err := s.db.QueryRow(ctx, "SELECT user_id FROM sessions WHERE token_hash=$1 AND expires_at>$2", digest(token), s.now()).Scan(&user)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
// write serializes a user's mutations. Project removal, record writes and
|
||||
// idempotency receipts therefore see a consistent state even across devices.
|
||||
func (s *Store) write(ctx context.Context, user string, fn func(pgx.Tx) error) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var id string
|
||||
if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", user).Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Archived bool `json:"archived"`
|
||||
HasRecords bool `json:"hasRecords"`
|
||||
}
|
||||
|
||||
func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Project, error) {
|
||||
rows, err := s.db.Query(ctx, `SELECT p.id,p.name,p.archived,EXISTS(SELECT 1 FROM records r WHERE r.user_id=p.user_id AND r.project_id=p.id)
|
||||
FROM projects p WHERE p.user_id=$1 AND ($2 OR NOT p.archived) ORDER BY p.position`, user, archived)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
projects := []Project{}
|
||||
for rows.Next() {
|
||||
var p Project
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Archived, &p.HasRecords); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projects = append(projects, p)
|
||||
}
|
||||
return projects, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SaveProject(ctx context.Context, user, id, name string) (Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || utf8.RuneCountInString(name) > NameLimit || strings.ContainsRune(name, 0) {
|
||||
return Project{}, ErrInvalid
|
||||
}
|
||||
p := Project{ID: id, Name: name}
|
||||
err := s.write(ctx, user, func(tx pgx.Tx) error {
|
||||
if id == "" {
|
||||
p.ID = newID()
|
||||
_, err := tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", p.ID, user, name)
|
||||
return err
|
||||
}
|
||||
err := tx.QueryRow(ctx, `UPDATE projects SET name=$3 WHERE user_id=$1 AND id=$2 RETURNING archived,
|
||||
EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)`, user, id, name).Scan(&p.Archived, &p.HasRecords)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrUnavailable
|
||||
}
|
||||
return err
|
||||
})
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveProject(ctx context.Context, user, id string) (string, error) {
|
||||
mode := "deleted"
|
||||
err := s.write(ctx, user, func(tx pgx.Tx) error {
|
||||
var used bool
|
||||
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)
|
||||
FROM projects WHERE user_id=$1 AND id=$2`, user, id).Scan(&used)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if used {
|
||||
mode = "archived"
|
||||
_, err = tx.Exec(ctx, "UPDATE projects SET archived=true WHERE user_id=$1 AND id=$2", user, id)
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, "DELETE FROM projects WHERE user_id=$1 AND id=$2", user, id)
|
||||
}
|
||||
return err
|
||||
})
|
||||
return mode, err
|
||||
}
|
||||
Reference in New Issue
Block a user