200 lines
6.5 KiB
Go
200 lines
6.5 KiB
Go
|
|
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
|
||
|
|
})
|
||
|
|
}
|