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 }) }