231 lines
6.1 KiB
Go
231 lines
6.1 KiB
Go
package database
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var (
|
|
ErrUnauthorizedOrMissing = errors.New("item not found or unauthorized")
|
|
ErrVersionNotFound = errors.New("version not found")
|
|
ErrDeletionFailed = errors.New("deletion failed")
|
|
)
|
|
|
|
type Note struct {
|
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
|
UserID string `gorm:"type:uuid;not null;index"`
|
|
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
|
Versions []NoteVersion `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"`
|
|
}
|
|
|
|
type NoteVersion struct {
|
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
|
NoteID string `gorm:"type:uuid;not null;index"`
|
|
Title string `gorm:"type:varchar(255);not null"`
|
|
Content string `gorm:"type:text;not null"` // TOAST will automatically compress at >2KB
|
|
VersionNumber int `gorm:"not null"`
|
|
ContentHash string `gorm:"type:varchar(64);not null;index"`
|
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
|
}
|
|
|
|
// Helper hook that runs automatically before a new note version is created. It calculates a
|
|
// composite SHA256 hash of the title-content combination and looks up thte valid version
|
|
// number based on any previous entries in the table.
|
|
func (nv *NoteVersion) BeforeCreate(tx *gorm.DB) (err error) {
|
|
combined := nv.Title + "\n" + nv.Content
|
|
hash := sha256.Sum256([]byte(combined))
|
|
nv.ContentHash = hex.EncodeToString(hash[:])
|
|
|
|
var maxVersion int
|
|
err = tx.Model(&NoteVersion{}).
|
|
Where("note_id = ?", nv.NoteID).
|
|
Select("COALESCE(MAX(version_number), 0)").
|
|
Scan(&maxVersion).
|
|
Error
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
nv.VersionNumber = maxVersion + 1
|
|
return nil
|
|
}
|
|
|
|
// NOTE: User ID should always be injected to the requests by a middleware (and not taken from
|
|
// a user input).
|
|
|
|
// Create a new note and its initial version. Might return an error if the creation transaction
|
|
// fails. On success returns a pointer to the newly created note object.
|
|
func CreateNote(db *gorm.DB, userID string, title string, content string) (*Note, error) {
|
|
note := Note{
|
|
UserID: userID,
|
|
}
|
|
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(¬e).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Create(&NoteVersion{
|
|
NoteID: note.ID,
|
|
Title: title,
|
|
Content: content,
|
|
}).Error
|
|
})
|
|
|
|
return ¬e, err
|
|
}
|
|
|
|
// Get a list of `limit` notes belonging to the given user and their historical versions (preloaded
|
|
// version numbers).
|
|
func ListNotes(db *gorm.DB, userID string, page int, limit int) ([]Note, error) {
|
|
var notes []Note
|
|
offset := (page - 1) * limit
|
|
|
|
err := db.
|
|
Preload("Versions", func(db *gorm.DB) *gorm.DB {
|
|
return db.Order("note_versions.version_number DESC").Limit(1)
|
|
}).
|
|
Where("user_id = ?", userID).
|
|
Order("created_at DESC").
|
|
Offset(offset).
|
|
Limit(limit).
|
|
Find(¬es).
|
|
Error
|
|
|
|
return notes, err
|
|
}
|
|
|
|
// Get a single note with its preloaded version numbers.
|
|
func GetNote(db *gorm.DB, userID string, noteID string) (*Note, error) {
|
|
var note Note
|
|
err := db.
|
|
Preload("Versions", func(db *gorm.DB) *gorm.DB {
|
|
return db.Order("note_versions.version_number DESC")
|
|
}).
|
|
Where("id = ? AND user_id = ?", noteID, userID).
|
|
First(¬e).
|
|
Error
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrUnauthorizedOrMissing
|
|
}
|
|
|
|
return ¬e, err
|
|
}
|
|
|
|
// Get all the linked historical versions of the given note.
|
|
func GetNoteVersions(db *gorm.DB, userID string, noteID string, page int, limit int) ([]NoteVersion, error) {
|
|
var versions []NoteVersion
|
|
|
|
var note Note
|
|
if err := db.
|
|
Select("id").
|
|
Where("id = ? AND user_id = ?", noteID, userID).
|
|
First(¬e).
|
|
Error; err != nil {
|
|
return nil, ErrUnauthorizedOrMissing
|
|
}
|
|
|
|
offset := (page - 1) * limit
|
|
err := db.
|
|
Where("note_id = ?", noteID).
|
|
Order("version_number DESC").
|
|
Offset(offset).
|
|
Limit(limit).
|
|
Find(&versions).
|
|
Error
|
|
|
|
return versions, err
|
|
}
|
|
|
|
// Get a specific historical version of the given note.
|
|
func GetNoteVersion(db *gorm.DB, userID string, noteID string, versionNumber int) (*NoteVersion, error) {
|
|
var version NoteVersion
|
|
|
|
// Ownership check via note
|
|
var note Note
|
|
if err := db.
|
|
Select("id").
|
|
Where("id = ? AND user_id = ?", noteID, userID).
|
|
First(¬e).
|
|
Error; err != nil {
|
|
return nil, ErrUnauthorizedOrMissing
|
|
}
|
|
|
|
err := db.
|
|
Where("note_id = ? AND version_number = ?", noteID, versionNumber).
|
|
First(&version).
|
|
Error
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrVersionNotFound
|
|
}
|
|
|
|
return &version, err
|
|
}
|
|
|
|
// Update an existing note by creating a new version. If the composite SHA256 hash of the new
|
|
// version contents matches with an already stored version, the new version will be skipped.
|
|
func UpdateNote(db *gorm.DB, userID string, noteID string, newTitle string, newContent string) error {
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
var note Note
|
|
if err := tx.
|
|
Where("id = ? AND user_id = ?", noteID, userID).
|
|
First(¬e).
|
|
Error; err != nil {
|
|
return ErrUnauthorizedOrMissing
|
|
}
|
|
|
|
var latest NoteVersion
|
|
if err := tx.
|
|
Where("note_id = ?", noteID).
|
|
Order("version_number DESC").
|
|
First(&latest).
|
|
Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
combined := newTitle + "\n" + newContent
|
|
newHash := sha256.Sum256([]byte(combined))
|
|
if hex.EncodeToString(newHash[:]) == latest.ContentHash {
|
|
return nil // No changes
|
|
}
|
|
|
|
return tx.Create(&NoteVersion{
|
|
NoteID: noteID,
|
|
Title: newTitle,
|
|
Content: newContent,
|
|
}).Error
|
|
})
|
|
}
|
|
|
|
// Remove a note and its versions permanently (hard delete).
|
|
func DeleteNote(db *gorm.DB, userID string, noteID string) error {
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
var note Note
|
|
if err := tx.
|
|
Where("id = ? AND user_id = ?", noteID, userID).
|
|
First(¬e).
|
|
Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrUnauthorizedOrMissing
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Delete note (cascades to versions)
|
|
if err := tx.Delete(¬e).Error; err != nil {
|
|
return ErrDeletionFailed
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|