mirror of
https://github.com/dnote/dnote
synced 2026-03-16 07:25:49 +01:00
Delete unused
This commit is contained in:
parent
232c443d46
commit
e2e991e3d4
41 changed files with 263 additions and 4648 deletions
|
|
@ -23,11 +23,7 @@ brew upgrade dnote
|
|||
|
||||
On Linux or macOS, you can use the installation script:
|
||||
|
||||
curl -s https://raw.githubusercontent.com/dnote/dnote/master/pkg/cli/install.sh | sh
|
||||
|
||||
In some cases, you might need an elevated permission:
|
||||
|
||||
curl -s https://raw.githubusercontent.com/dnote/dnote/master/pkg/cli/install.sh | sudo sh
|
||||
curl -s https://dl.getdnote.com | sh
|
||||
|
||||
Otherwise, you can download the binary for your platform manually from the [releases page](https://github.com/dnote/dnote/releases).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,206 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (a *App) getExistingDigestReceipt(userID, digestID int) (*database.DigestReceipt, error) {
|
||||
var ret database.DigestReceipt
|
||||
conn := a.DB.Where("user_id = ? AND digest_id = ?", userID, digestID).First(&ret)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
return nil, nil
|
||||
}
|
||||
if err := conn.Error; err != nil {
|
||||
return nil, errors.Wrap(err, "querying existing digest receipt")
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
// GetUserDigestByUUID retrives a digest by the uuid for the given user
|
||||
func (a *App) GetUserDigestByUUID(userID int, uuid string) (*database.Digest, error) {
|
||||
var ret database.Digest
|
||||
conn := a.DB.Where("user_id = ? AND uuid = ?", userID, uuid).First(&ret)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
return nil, nil
|
||||
}
|
||||
if err := conn.Error; err != nil {
|
||||
return nil, errors.Wrap(err, "finding digest")
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
// MarkDigestRead creates a new digest receipt. If one already exists for
|
||||
// the given digest and the user, it is a noop.
|
||||
func (a *App) MarkDigestRead(digest database.Digest, user database.User) (database.DigestReceipt, error) {
|
||||
db := a.DB
|
||||
|
||||
existing, err := a.getExistingDigestReceipt(user.ID, digest.ID)
|
||||
if err != nil {
|
||||
return database.DigestReceipt{}, errors.Wrap(err, "checking existing digest receipt")
|
||||
}
|
||||
if existing != nil {
|
||||
return *existing, nil
|
||||
}
|
||||
|
||||
dat := database.DigestReceipt{
|
||||
UserID: user.ID,
|
||||
DigestID: digest.ID,
|
||||
}
|
||||
if err := db.Create(&dat).Error; err != nil {
|
||||
return database.DigestReceipt{}, errors.Wrap(err, "creating digest receipt")
|
||||
}
|
||||
|
||||
return dat, nil
|
||||
}
|
||||
|
||||
// GetDigestsParam is the params for getting a list of digests
|
||||
type GetDigestsParam struct {
|
||||
UserID int
|
||||
Status string
|
||||
Offset int
|
||||
PerPage int
|
||||
Order string
|
||||
}
|
||||
|
||||
func (p GetDigestsParam) getSubQuery() string {
|
||||
orderClause := p.getOrderClause("digests")
|
||||
|
||||
return fmt.Sprintf(`SELECT
|
||||
digests.id AS digest_id,
|
||||
digests.created_at AS created_at,
|
||||
COUNT(digest_receipts.id) AS receipt_count
|
||||
FROM digests
|
||||
LEFT JOIN digest_receipts ON digest_receipts.digest_id = digests.id
|
||||
WHERE digests.user_id = %d
|
||||
GROUP BY digests.id, digests.created_at
|
||||
%s`, p.UserID, orderClause)
|
||||
}
|
||||
|
||||
func (p GetDigestsParam) getSubQueryWhere() string {
|
||||
var ret string
|
||||
|
||||
if p.Status == "unread" {
|
||||
ret = "WHERE t1.receipt_count = 0"
|
||||
} else if p.Status == "read" {
|
||||
ret = "WHERE t1.receipt_count > 0"
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p GetDigestsParam) getOrderClause(table string) string {
|
||||
if p.Order == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`ORDER BY %s.%s`, table, p.Order)
|
||||
}
|
||||
|
||||
// CountDigests counts digests with the given user using the given criteria
|
||||
func (a *App) CountDigests(p GetDigestsParam) (int, error) {
|
||||
subquery := p.getSubQuery()
|
||||
whereClause := p.getSubQueryWhere()
|
||||
query := fmt.Sprintf(`SELECT COUNT(*) FROM (%s) AS t1 %s`, subquery, whereClause)
|
||||
|
||||
result := struct {
|
||||
Count int
|
||||
}{}
|
||||
if err := a.DB.Raw(query).Scan(&result).Error; err != nil {
|
||||
return 0, errors.Wrap(err, "running count query")
|
||||
}
|
||||
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
func (a *App) queryDigestIDs(p GetDigestsParam) ([]int, error) {
|
||||
subquery := p.getSubQuery()
|
||||
whereClause := p.getSubQueryWhere()
|
||||
orderClause := p.getOrderClause("t1")
|
||||
query := fmt.Sprintf(`SELECT t1.digest_id FROM (%s) AS t1 %s %s OFFSET ? LIMIT ?;`, subquery, whereClause, orderClause)
|
||||
|
||||
ret := []int{}
|
||||
rows, err := a.DB.Raw(query, p.Offset, p.PerPage).Rows()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getting rows")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return []int{}, errors.Wrap(err, "scanning row")
|
||||
}
|
||||
|
||||
ret = append(ret, id)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// GetDigests queries digests for the given user using the given criteria
|
||||
func (a *App) GetDigests(p GetDigestsParam) ([]database.Digest, error) {
|
||||
IDs, err := a.queryDigestIDs(p)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "querying digest IDs")
|
||||
}
|
||||
|
||||
var ret []database.Digest
|
||||
conn := a.DB.Where("id IN (?)", IDs).
|
||||
Order(p.Order).Preload("Rule").Preload("Receipts").
|
||||
Find(&ret)
|
||||
if err := conn.Error; err != nil && !conn.RecordNotFound() {
|
||||
return nil, errors.Wrap(err, "finding digests")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// PreloadDigest preloads associations for the given digest. It returns a new digest.
|
||||
func (a *App) PreloadDigest(d database.Digest) (database.Digest, error) {
|
||||
var ret database.Digest
|
||||
|
||||
conn := a.DB.Where("id = ?", d.ID).
|
||||
Preload("Notes", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("notes.created_at DESC")
|
||||
}).
|
||||
Preload("Notes.Book").
|
||||
Preload("Notes.NoteReview", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("note_reviews.digest_id = ?", d.ID)
|
||||
}).
|
||||
Preload("Rule").
|
||||
Preload("Receipts", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("digest_receipts.user_id = ?", d.UserID)
|
||||
}).First(&ret)
|
||||
|
||||
if err := conn.Error; err != nil {
|
||||
return ret, errors.Wrap(err, "preloading")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestMarkDigestRead(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
digest := database.Digest{UserID: user.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest")
|
||||
|
||||
a := NewTest(nil)
|
||||
|
||||
// Multiple calls should not create more than 1 receipt
|
||||
for i := 0; i < 3; i++ {
|
||||
ret, err := a.MarkDigestRead(digest, user)
|
||||
if err != nil {
|
||||
t.Fatal(err, "failed to perform")
|
||||
}
|
||||
|
||||
var receiptCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.DigestReceipt{}).Count(&receiptCount), "counting receipts")
|
||||
assert.Equalf(t, receiptCount, 1, "receipt count mismatch")
|
||||
|
||||
var receipt database.DigestReceipt
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", ret.ID).First(&receipt), "getting receipt")
|
||||
assert.Equalf(t, receipt.UserID, user.ID, "receipt UserID mismatch")
|
||||
assert.Equalf(t, receipt.DigestID, digest.ID, "receipt DigestID mismatch")
|
||||
}
|
||||
}
|
||||
|
|
@ -156,11 +156,6 @@ func (a *App) DeleteNote(tx *gorm.DB, user database.User, note database.Note) (d
|
|||
return note, errors.Wrap(err, "deleting note")
|
||||
}
|
||||
|
||||
// Delete associations
|
||||
if err := tx.Where("note_id = ?", note.ID).Delete(&database.DigestNote{}).Error; err != nil {
|
||||
return note, errors.Wrap(err, "deleting digest_notes")
|
||||
}
|
||||
|
||||
return note, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,25 +49,6 @@ func createEmailPreference(user database.User, tx *gorm.DB) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func createDefaultRepetitionRule(user database.User, tx *gorm.DB) error {
|
||||
r := database.RepetitionRule{
|
||||
Title: "Default repetition - all books",
|
||||
UserID: user.ID,
|
||||
Enabled: false,
|
||||
Hour: 20,
|
||||
Minute: 30,
|
||||
Frequency: 604800000,
|
||||
BookDomain: database.BookDomainAll,
|
||||
Books: []database.Book{},
|
||||
NoteCount: 20,
|
||||
}
|
||||
if err := tx.Save(&r).Error; err != nil {
|
||||
return errors.Wrap(err, "inserting repetition rule")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUser creates a user
|
||||
func (a *App) CreateUser(email, password string) (database.User, error) {
|
||||
tx := a.DB.Begin()
|
||||
|
|
@ -111,10 +92,6 @@ func (a *App) CreateUser(email, password string) (database.User, error) {
|
|||
tx.Rollback()
|
||||
return database.User{}, errors.Wrap(err, "creating email preference")
|
||||
}
|
||||
if err := createDefaultRepetitionRule(user, tx); err != nil {
|
||||
tx.Rollback()
|
||||
return database.User{}, errors.Wrap(err, "creating default repetition rule")
|
||||
}
|
||||
if err := a.TouchLastLoginAt(user, tx); err != nil {
|
||||
tx.Rollback()
|
||||
return database.User{}, errors.Wrap(err, "updating last login")
|
||||
|
|
|
|||
|
|
@ -45,11 +45,6 @@ func InitSchema(db *gorm.DB) {
|
|||
Token{},
|
||||
EmailPreference{},
|
||||
Session{},
|
||||
Digest{},
|
||||
DigestNote{},
|
||||
RepetitionRule{},
|
||||
DigestReceipt{},
|
||||
NoteReview{},
|
||||
).Error; err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,20 +46,19 @@ type Book struct {
|
|||
// Note is a model for a note
|
||||
type Note struct {
|
||||
Model
|
||||
UUID string `json:"uuid" gorm:"index;type:uuid;default:uuid_generate_v4()"`
|
||||
Book Book `json:"book" gorm:"foreignkey:BookUUID"`
|
||||
User User `json:"user"`
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
BookUUID string `json:"book_uuid" gorm:"index;type:uuid"`
|
||||
Body string `json:"content"`
|
||||
AddedOn int64 `json:"added_on"`
|
||||
EditedOn int64 `json:"edited_on"`
|
||||
TSV string `json:"-" gorm:"type:tsvector"`
|
||||
Public bool `json:"public" gorm:"default:false"`
|
||||
USN int `json:"-" gorm:"index"`
|
||||
Deleted bool `json:"-" gorm:"default:false"`
|
||||
Encrypted bool `json:"-" gorm:"default:false"`
|
||||
NoteReview NoteReview `json:"-"`
|
||||
UUID string `json:"uuid" gorm:"index;type:uuid;default:uuid_generate_v4()"`
|
||||
Book Book `json:"book" gorm:"foreignkey:BookUUID"`
|
||||
User User `json:"user"`
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
BookUUID string `json:"book_uuid" gorm:"index;type:uuid"`
|
||||
Body string `json:"content"`
|
||||
AddedOn int64 `json:"added_on"`
|
||||
EditedOn int64 `json:"edited_on"`
|
||||
TSV string `json:"-" gorm:"type:tsvector"`
|
||||
Public bool `json:"public" gorm:"default:false"`
|
||||
USN int `json:"-" gorm:"index"`
|
||||
Deleted bool `json:"-" gorm:"default:false"`
|
||||
Encrypted bool `json:"-" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// User is a model for a user
|
||||
|
|
@ -126,59 +125,3 @@ type Session struct {
|
|||
LastUsedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Digest is a digest of notes
|
||||
type Digest struct {
|
||||
Model
|
||||
UUID string `json:"uuid" gorm:"type:uuid;index;default:uuid_generate_v4()"`
|
||||
RuleID int `gorm:"index"`
|
||||
Rule RepetitionRule `json:"rule"`
|
||||
UserID int `gorm:"index"`
|
||||
Version int `gorm:"version"`
|
||||
Notes []Note `gorm:"many2many:digest_notes;"`
|
||||
Receipts []DigestReceipt `gorm:"polymorphic:Target;"`
|
||||
}
|
||||
|
||||
// DigestNote is an intermediary to represent many-to-many relationship
|
||||
// between digests and notes
|
||||
type DigestNote struct {
|
||||
Model
|
||||
NoteID int `gorm:"index"`
|
||||
DigestID int `gorm:"index"`
|
||||
}
|
||||
|
||||
// RepetitionRule is the rules for sending digest emails
|
||||
type RepetitionRule struct {
|
||||
Model
|
||||
UUID string `json:"uuid" gorm:"type:uuid;index;default:uuid_generate_v4()"`
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
Title string `json:"title"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Hour int `json:"hour" gorm:"index"`
|
||||
Minute int `json:"minute" gorm:"index"`
|
||||
// in milliseconds
|
||||
Frequency int64 `json:"frequency"`
|
||||
// in milliseconds
|
||||
LastActive int64 `json:"last_active"`
|
||||
// in milliseconds
|
||||
NextActive int64 `json:"next_active"`
|
||||
BookDomain string `json:"book_domain"`
|
||||
Books []Book `gorm:"many2many:repetition_rule_books;"`
|
||||
NoteCount int `json:"note_count"`
|
||||
}
|
||||
|
||||
// DigestReceipt is a read receipt for digests
|
||||
type DigestReceipt struct {
|
||||
Model
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
DigestID int `json:"digest_id" gorm:"index"`
|
||||
}
|
||||
|
||||
// NoteReview is a record for reviewing a note in a digest
|
||||
type NoteReview struct {
|
||||
Model
|
||||
UUID string `json:"uuid" gorm:"index;type:uuid;default:uuid_generate_v4()"`
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
DigestID int `json:"digest_id" gorm:"index"`
|
||||
NoteID int `json:"note_id" gorm:"index"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (a *API) getDigest(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
digestUUID := vars["digestUUID"]
|
||||
|
||||
d, err := a.App.GetUserDigestByUUID(user.ID, digestUUID)
|
||||
if d == nil {
|
||||
RespondNotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding digest", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
digest, err := a.App.PreloadDigest(*d)
|
||||
if err != nil {
|
||||
HandleError(w, "finding digest", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// mark as read
|
||||
if _, err := a.App.MarkDigestRead(digest, user); err != nil {
|
||||
log.ErrorWrap(err, fmt.Sprintf("marking digest as read for %s", digest.UUID))
|
||||
}
|
||||
|
||||
presented := presenters.PresentDigest(digest)
|
||||
respondJSON(w, http.StatusOK, presented)
|
||||
}
|
||||
|
||||
// DigestsResponse is a response for getting digests
|
||||
type DigestsResponse struct {
|
||||
Total int `json:"total"`
|
||||
Items []presenters.Digest `json:"items"`
|
||||
}
|
||||
|
||||
type getDigestsParams struct {
|
||||
page int
|
||||
status string
|
||||
}
|
||||
|
||||
func parseGetDigestsParams(r *http.Request) (getDigestsParams, error) {
|
||||
var page int
|
||||
var err error
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
pageStr := q.Get("page")
|
||||
if pageStr != "" {
|
||||
page, err = strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
return getDigestsParams{}, errors.Wrap(err, "parsing page")
|
||||
}
|
||||
} else {
|
||||
page = 1
|
||||
}
|
||||
|
||||
status := q.Get("status")
|
||||
|
||||
return getDigestsParams{
|
||||
page: page,
|
||||
status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *API) getDigests(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
params, err := parseGetDigestsParams(r)
|
||||
if err != nil {
|
||||
HandleError(w, "parsing params", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
perPage := 30
|
||||
offset := (params.page - 1) * perPage
|
||||
p := app.GetDigestsParam{
|
||||
UserID: user.ID,
|
||||
Offset: offset,
|
||||
PerPage: perPage,
|
||||
Status: params.status,
|
||||
Order: "created_at DESC",
|
||||
}
|
||||
|
||||
digests, err := a.App.GetDigests(p)
|
||||
if err != nil {
|
||||
HandleError(w, "querying digests", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
total, err := a.App.CountDigests(p)
|
||||
if err != nil {
|
||||
HandleError(w, "counting digests", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, DigestsResponse{
|
||||
Total: total,
|
||||
Items: presenters.PresentDigests(digests),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestGetDigest_Permission(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, nil)
|
||||
defer server.Close()
|
||||
|
||||
owner := testutils.SetupUserData()
|
||||
nonOwner := testutils.SetupUserData()
|
||||
digest := database.Digest{
|
||||
UserID: owner.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest")
|
||||
|
||||
t.Run("owner", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPAuthDo(t, req, owner)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
})
|
||||
|
||||
t.Run("non owner", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPAuthDo(t, req, nonOwner)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
})
|
||||
|
||||
t.Run("guest", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetDigest_Receipt(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, nil)
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
digest := database.Digest{
|
||||
UserID: user.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest")
|
||||
|
||||
// multiple requests should create at most one receipt
|
||||
for i := 0; i < 3; i++ {
|
||||
// Execute and test
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var receiptCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.DigestReceipt{}).Count(&receiptCount), "counting receipts")
|
||||
assert.Equal(t, receiptCount, 1, "counting receipt")
|
||||
|
||||
var receipt database.DigestReceipt
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&receipt), "finding receipt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDigests(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, nil)
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
digest := database.Digest{
|
||||
UserID: user.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&digest), "preparing digest")
|
||||
|
||||
t.Run("user", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", "/digests", "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
})
|
||||
|
||||
t.Run("guest", func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/digests/%s", digest.UUID), "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type createNoteReviewParams struct {
|
||||
DigestUUID string `json:"digest_uuid"`
|
||||
NoteUUID string `json:"note_uuid"`
|
||||
}
|
||||
|
||||
func getDigestByUUID(db *gorm.DB, uuid string) (*database.Digest, error) {
|
||||
var ret database.Digest
|
||||
conn := db.Where("uuid = ?", uuid).First(&ret)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
return nil, nil
|
||||
}
|
||||
if err := conn.Error; err != nil {
|
||||
return nil, errors.Wrap(err, "finding digest")
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (a *API) createNoteReview(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var params createNoteReviewParams
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if err != nil {
|
||||
HandleError(w, "decoding params", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
digest, err := a.App.GetUserDigestByUUID(user.ID, params.DigestUUID)
|
||||
if digest == nil {
|
||||
http.Error(w, "digest not found for the given uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding digest", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
note, err := a.App.GetUserNoteByUUID(user.ID, params.NoteUUID)
|
||||
if note == nil {
|
||||
http.Error(w, "note not found for the given uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var nr database.NoteReview
|
||||
if err := a.App.DB.FirstOrCreate(&nr, database.NoteReview{
|
||||
UserID: user.ID,
|
||||
DigestID: digest.ID,
|
||||
NoteID: note.ID,
|
||||
}).Error; err != nil {
|
||||
HandleError(w, "saving note review", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type deleteNoteReviewParams struct {
|
||||
DigestUUID string `json:"digest_uuid"`
|
||||
NoteUUID string `json:"note_uuid"`
|
||||
}
|
||||
|
||||
func (a *API) deleteNoteReview(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var params deleteNoteReviewParams
|
||||
err := json.NewDecoder(r.Body).Decode(¶ms)
|
||||
if err != nil {
|
||||
HandleError(w, "decoding params", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
db := a.App.DB
|
||||
|
||||
note, err := a.App.GetUserNoteByUUID(user.ID, params.NoteUUID)
|
||||
if note == nil {
|
||||
http.Error(w, "note not found for the given uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
digest, err := a.App.GetUserDigestByUUID(user.ID, params.DigestUUID)
|
||||
if digest == nil {
|
||||
http.Error(w, "digest not found for the given uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
HandleError(w, "finding digest", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var nr database.NoteReview
|
||||
conn := db.Where("note_id = ? AND digest_id = ? AND user_id = ?", note.ID, digest.ID, user.ID).First(&nr)
|
||||
if conn.RecordNotFound() {
|
||||
http.Error(w, "no record found", http.StatusBadRequest)
|
||||
return
|
||||
} else if err := conn.Error; err != nil {
|
||||
HandleError(w, "finding record", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Delete(&nr).Error; err != nil {
|
||||
HandleError(w, "deleting record", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestCreateNoteReview(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
d1 := database.Digest{
|
||||
UserID: user.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&d1), "preparing d1")
|
||||
|
||||
// multiple requests should create at most one receipt
|
||||
for i := 0; i < 3; i++ {
|
||||
dat := fmt.Sprintf(`{"note_uuid": "%s", "digest_uuid": "%s"}`, n1.UUID, d1.UUID)
|
||||
req := testutils.MakeReq(server.URL, http.MethodPost, "/note_review", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var noteReviewCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.NoteReview{}).Count(¬eReviewCount), "counting note_reviews")
|
||||
assert.Equalf(t, noteReviewCount, 1, "counting note_review")
|
||||
|
||||
var noteReviewRecord database.NoteReview
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND note_id = ? AND digest_id = ?", user.ID, n1.ID, d1.ID).First(¬eReviewRecord), "finding note_review record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNoteReview(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
d1 := database.Digest{
|
||||
UserID: user.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&d1), "preparing d1")
|
||||
nr1 := database.NoteReview{
|
||||
UserID: user.ID,
|
||||
NoteID: n1.ID,
|
||||
DigestID: d1.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&nr1), "preparing nr1")
|
||||
|
||||
dat := fmt.Sprintf(`{"note_uuid": "%s", "digest_uuid": "%s"}`, n1.UUID, d1.UUID)
|
||||
req := testutils.MakeReq(server.URL, http.MethodDelete, "/note_review", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var noteReviewCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.NoteReview{}).Count(¬eReviewCount), "counting note_reviews")
|
||||
assert.Equal(t, noteReviewCount, 0, "counting note_review")
|
||||
}
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (a *API) getRepetitionRule(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
repetitionRuleUUID := vars["repetitionRuleUUID"]
|
||||
|
||||
if ok := helpers.ValidateUUID(repetitionRuleUUID); !ok {
|
||||
http.Error(w, "invalid uuid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var repetitionRule database.RepetitionRule
|
||||
if err := a.App.DB.Where("user_id = ? AND uuid = ?", user.ID, repetitionRuleUUID).Preload("Books").Find(&repetitionRule).Error; err != nil {
|
||||
HandleError(w, "getting repetition rules", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := presenters.PresentRepetitionRule(repetitionRule)
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (a *API) getRepetitionRules(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var repetitionRules []database.RepetitionRule
|
||||
if err := a.App.DB.Where("user_id = ?", user.ID).Preload("Books").Order("last_active DESC").Find(&repetitionRules).Error; err != nil {
|
||||
HandleError(w, "getting repetition rules", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := presenters.PresentRepetitionRules(repetitionRules)
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func validateBookDomain(val string) error {
|
||||
if val == database.BookDomainAll || val == database.BookDomainIncluding || val == database.BookDomainExluding {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.Errorf("invalid book_domain %s", val)
|
||||
}
|
||||
|
||||
type repetitionRuleParams struct {
|
||||
Title *string `json:"title"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Hour *int `json:"hour"`
|
||||
Minute *int `json:"minute"`
|
||||
Frequency *int64 `json:"frequency"`
|
||||
BookDomain *string `json:"book_domain"`
|
||||
BookUUIDs *[]string `json:"book_uuids"`
|
||||
NoteCount *int `json:"note_count"`
|
||||
}
|
||||
|
||||
func (r repetitionRuleParams) GetEnabled() bool {
|
||||
if r.Enabled == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return *r.Enabled
|
||||
}
|
||||
|
||||
func (r repetitionRuleParams) GetFrequency() int64 {
|
||||
if r.Frequency == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return *r.Frequency
|
||||
}
|
||||
|
||||
func (r repetitionRuleParams) GetTitle() string {
|
||||
if r.Title == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *r.Title
|
||||
}
|
||||
|
||||
func (r repetitionRuleParams) GetNoteCount() int {
|
||||
if r.NoteCount == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return *r.NoteCount
|
||||
}
|
||||
|
||||
func (r repetitionRuleParams) GetBookDomain() string {
|
||||
if r.BookDomain == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *r.BookDomain
|
||||
}
|
||||
|
||||
func (r repetitionRuleParams) GetBookUUIDs() []string {
|
||||
if r.BookUUIDs == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return *r.BookUUIDs
|
||||
}
|
||||
|
||||
func (r repetitionRuleParams) GetHour() int {
|
||||
if r.Hour == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return *r.Hour
|
||||
}
|
||||
|
||||
func (r repetitionRuleParams) GetMinute() int {
|
||||
if r.Minute == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return *r.Minute
|
||||
}
|
||||
|
||||
func validateRepetitionRuleParams(p repetitionRuleParams) error {
|
||||
if p.Frequency != nil && p.GetFrequency() == 0 {
|
||||
return errors.New("frequency is required")
|
||||
}
|
||||
|
||||
if p.Title != nil {
|
||||
title := p.GetTitle()
|
||||
|
||||
if len(title) == 0 {
|
||||
return errors.New("Title is required")
|
||||
}
|
||||
if len(title) > 50 {
|
||||
return errors.New("Title is too long")
|
||||
}
|
||||
}
|
||||
|
||||
if p.NoteCount != nil && p.GetNoteCount() == 0 {
|
||||
return errors.New("note count has to be greater than 0")
|
||||
}
|
||||
|
||||
if p.BookDomain != nil {
|
||||
bookDomain := p.GetBookDomain()
|
||||
if err := validateBookDomain(bookDomain); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bookUUIDs := p.GetBookUUIDs()
|
||||
if bookDomain == database.BookDomainAll {
|
||||
if len(bookUUIDs) > 0 {
|
||||
return errors.New("a global repetition should not specify book_uuids")
|
||||
}
|
||||
} else {
|
||||
if len(bookUUIDs) == 0 {
|
||||
return errors.New("book_uuids is required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if p.Hour != nil {
|
||||
hour := p.GetHour()
|
||||
|
||||
if hour < 0 && hour > 23 {
|
||||
return errors.New("invalid hour")
|
||||
}
|
||||
}
|
||||
|
||||
if p.Minute != nil {
|
||||
minute := p.GetMinute()
|
||||
|
||||
if minute < 0 && minute > 60 {
|
||||
return errors.New("invalid minute")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCreateRepetitionRuleParams(p repetitionRuleParams) error {
|
||||
if p.Title == nil {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
if p.Frequency == nil {
|
||||
return errors.New("frequency is required")
|
||||
}
|
||||
if p.NoteCount == nil {
|
||||
return errors.New("note_count is required")
|
||||
}
|
||||
if p.BookDomain == nil {
|
||||
return errors.New("book_domain is required")
|
||||
}
|
||||
if p.Hour == nil {
|
||||
return errors.New("hour is required")
|
||||
}
|
||||
if p.Minute == nil {
|
||||
return errors.New("minute is required")
|
||||
}
|
||||
if p.Enabled == nil {
|
||||
return errors.New("enabled is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCreateRepetitionRuleParams(r *http.Request) (repetitionRuleParams, error) {
|
||||
var ret repetitionRuleParams
|
||||
|
||||
d := json.NewDecoder(r.Body)
|
||||
d.DisallowUnknownFields()
|
||||
|
||||
if err := d.Decode(&ret); err != nil {
|
||||
return ret, errors.Wrap(err, "decoding json")
|
||||
}
|
||||
|
||||
if err := validateCreateRepetitionRuleParams(ret); err != nil {
|
||||
return ret, errors.Wrap(err, "validating params")
|
||||
}
|
||||
|
||||
if err := validateRepetitionRuleParams(ret); err != nil {
|
||||
return ret, errors.Wrap(err, "validating params")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
type calcNextActiveParams struct {
|
||||
Hour int
|
||||
Minute int
|
||||
Frequency int64
|
||||
}
|
||||
|
||||
// calcNextActive calculates the NextActive value for a repetition rule by adding the given
|
||||
// frequency to the given present date time at the given hour and minute.
|
||||
func calcNextActive(now time.Time, p calcNextActiveParams) int64 {
|
||||
t0 := time.Date(now.Year(), now.Month(), now.Day(), p.Hour, p.Minute, 0, 0, now.Location()).UnixNano() / int64(time.Millisecond)
|
||||
|
||||
return t0 + p.Frequency
|
||||
}
|
||||
|
||||
func (a *API) createRepetitionRule(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
params, err := parseCreateRepetitionRuleParams(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var books []database.Book
|
||||
if err := a.App.DB.Where("user_id = ? AND uuid IN (?)", user.ID, params.GetBookUUIDs()).Find(&books).Error; err != nil {
|
||||
HandleError(w, "finding books", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
nextActive := calcNextActive(a.App.Clock.Now(), calcNextActiveParams{
|
||||
Hour: params.GetHour(),
|
||||
Minute: params.GetMinute(),
|
||||
Frequency: params.GetFrequency(),
|
||||
})
|
||||
|
||||
record := database.RepetitionRule{
|
||||
UserID: user.ID,
|
||||
Title: params.GetTitle(),
|
||||
Hour: params.GetHour(),
|
||||
Minute: params.GetMinute(),
|
||||
Frequency: params.GetFrequency(),
|
||||
BookDomain: params.GetBookDomain(),
|
||||
NextActive: nextActive,
|
||||
Books: books,
|
||||
NoteCount: params.GetNoteCount(),
|
||||
Enabled: params.GetEnabled(),
|
||||
}
|
||||
if err := a.App.DB.Create(&record).Error; err != nil {
|
||||
HandleError(w, "creating a repetition rule", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := presenters.PresentRepetitionRule(record)
|
||||
respondJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func parseUpdateDigestParams(r *http.Request) (repetitionRuleParams, error) {
|
||||
var ret repetitionRuleParams
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&ret); err != nil {
|
||||
return ret, errors.Wrap(err, "decoding json")
|
||||
}
|
||||
|
||||
if err := validateRepetitionRuleParams(ret); err != nil {
|
||||
return ret, errors.Wrap(err, "validating params")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (a *API) deleteRepetitionRule(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
repetitionRuleUUID := vars["repetitionRuleUUID"]
|
||||
|
||||
var rule database.RepetitionRule
|
||||
conn := a.App.DB.Where("uuid = ? AND user_id = ?", repetitionRuleUUID, user.ID).First(&rule)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
} else if err := conn.Error; err != nil {
|
||||
HandleError(w, "finding the repetition rule", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.App.DB.Exec("DELETE from repetition_rules WHERE uuid = ?", rule.UUID).Error; err != nil {
|
||||
HandleError(w, "deleting the repetition rule", err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *API) updateRepetitionRule(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
repetitionRuleUUID := vars["repetitionRuleUUID"]
|
||||
|
||||
params, err := parseUpdateDigestParams(r)
|
||||
if err != nil {
|
||||
http.Error(w, "parsing params", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx := a.App.DB.Begin()
|
||||
|
||||
var repetitionRule database.RepetitionRule
|
||||
if err := tx.Where("user_id = ? AND uuid = ?", user.ID, repetitionRuleUUID).Preload("Books").First(&repetitionRule).Error; err != nil {
|
||||
HandleError(w, "finding record", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if params.Title != nil {
|
||||
repetitionRule.Title = params.GetTitle()
|
||||
}
|
||||
if params.Enabled != nil {
|
||||
enabled := params.GetEnabled()
|
||||
repetitionRule.Enabled = enabled
|
||||
|
||||
if enabled && !repetitionRule.Enabled {
|
||||
repetitionRule.NextActive = calcNextActive(a.App.Clock.Now(), calcNextActiveParams{
|
||||
Hour: repetitionRule.Hour,
|
||||
Minute: repetitionRule.Minute,
|
||||
Frequency: repetitionRule.Frequency,
|
||||
})
|
||||
} else if !enabled && repetitionRule.Enabled {
|
||||
repetitionRule.NextActive = 0
|
||||
}
|
||||
}
|
||||
if params.Hour != nil {
|
||||
repetitionRule.Hour = params.GetHour()
|
||||
}
|
||||
if params.Minute != nil {
|
||||
repetitionRule.Minute = params.GetMinute()
|
||||
}
|
||||
if params.Frequency != nil {
|
||||
frequency := params.GetFrequency()
|
||||
|
||||
repetitionRule.Frequency = frequency
|
||||
repetitionRule.NextActive = calcNextActive(a.App.Clock.Now(), calcNextActiveParams{
|
||||
Hour: repetitionRule.Hour,
|
||||
Minute: repetitionRule.Minute,
|
||||
Frequency: frequency,
|
||||
})
|
||||
}
|
||||
if params.NoteCount != nil {
|
||||
repetitionRule.NoteCount = params.GetNoteCount()
|
||||
}
|
||||
if params.BookDomain != nil {
|
||||
repetitionRule.BookDomain = params.GetBookDomain()
|
||||
}
|
||||
if params.BookUUIDs != nil {
|
||||
var books []database.Book
|
||||
if err := tx.Where("user_id = ? AND uuid IN (?)", user.ID, *params.BookUUIDs).Find(&books).Error; err != nil {
|
||||
HandleError(w, "finding books", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Model(&repetitionRule).Association("Books").Replace(books).Error; err != nil {
|
||||
tx.Rollback()
|
||||
HandleError(w, "updating books association for a repetitionRule", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Save(&repetitionRule).Error; err != nil {
|
||||
tx.Rollback()
|
||||
HandleError(w, "creating a repetition rule", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
tx.Rollback()
|
||||
HandleError(w, "committing a transaction", err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
resp := presenters.PresentRepetitionRule(repetitionRule)
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
|
@ -1,656 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestGetRepetitionRule(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{
|
||||
USN: 11,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing book1")
|
||||
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
Frequency: (time.Hour * 24 * 7).Milliseconds(),
|
||||
Hour: 21,
|
||||
Minute: 0,
|
||||
LastActive: 0,
|
||||
UserID: user.ID,
|
||||
BookDomain: database.BookDomainExluding,
|
||||
Books: []database.Book{b1},
|
||||
NoteCount: 5,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/repetition_rules/%s", r1.UUID), "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.RepetitionRule
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var r1Record database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", r1.UUID).First(&r1Record), "finding r1Record")
|
||||
var b1Record database.Book
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", b1.UUID).First(&b1Record), "finding b1Record")
|
||||
|
||||
expected := presenters.RepetitionRule{
|
||||
UUID: r1Record.UUID,
|
||||
Title: r1Record.Title,
|
||||
Enabled: r1Record.Enabled,
|
||||
Hour: r1Record.Hour,
|
||||
Minute: r1Record.Minute,
|
||||
Frequency: r1Record.Frequency,
|
||||
BookDomain: r1Record.BookDomain,
|
||||
NoteCount: r1Record.NoteCount,
|
||||
LastActive: r1Record.LastActive,
|
||||
Books: []presenters.Book{
|
||||
{
|
||||
UUID: b1Record.UUID,
|
||||
USN: b1Record.USN,
|
||||
Label: b1Record.Label,
|
||||
CreatedAt: presenters.FormatTS(b1Record.CreatedAt),
|
||||
UpdatedAt: presenters.FormatTS(b1Record.UpdatedAt),
|
||||
},
|
||||
},
|
||||
CreatedAt: presenters.FormatTS(r1Record.CreatedAt),
|
||||
UpdatedAt: presenters.FormatTS(r1Record.UpdatedAt),
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
}
|
||||
|
||||
func TestGetRepetitionRules(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{
|
||||
USN: 11,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing book1")
|
||||
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
Frequency: (time.Hour * 24 * 7).Milliseconds(),
|
||||
Hour: 21,
|
||||
Minute: 0,
|
||||
LastActive: 1257714000000,
|
||||
UserID: user.ID,
|
||||
BookDomain: database.BookDomainExluding,
|
||||
Books: []database.Book{b1},
|
||||
NoteCount: 5,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
r2 := database.RepetitionRule{
|
||||
Title: "Rule 2",
|
||||
Frequency: (time.Hour * 24 * 7 * 2).Milliseconds(),
|
||||
Hour: 2,
|
||||
Minute: 0,
|
||||
LastActive: 0,
|
||||
UserID: user.ID,
|
||||
BookDomain: database.BookDomainExluding,
|
||||
Books: []database.Book{},
|
||||
NoteCount: 5,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r2), "preparing rule2")
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", "/repetition_rules", "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload []presenters.RepetitionRule
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var r1Record, r2Record database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", r1.UUID).First(&r1Record), "finding r1Record")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", r2.UUID).First(&r2Record), "finding r2Record")
|
||||
var b1Record database.Book
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", b1.UUID).First(&b1Record), "finding b1Record")
|
||||
|
||||
expected := []presenters.RepetitionRule{
|
||||
{
|
||||
UUID: r1Record.UUID,
|
||||
Title: r1Record.Title,
|
||||
Enabled: r1Record.Enabled,
|
||||
Hour: r1Record.Hour,
|
||||
Minute: r1Record.Minute,
|
||||
Frequency: r1Record.Frequency,
|
||||
BookDomain: r1Record.BookDomain,
|
||||
NoteCount: r1Record.NoteCount,
|
||||
LastActive: r1Record.LastActive,
|
||||
Books: []presenters.Book{
|
||||
{
|
||||
UUID: b1Record.UUID,
|
||||
USN: b1Record.USN,
|
||||
Label: b1Record.Label,
|
||||
CreatedAt: presenters.FormatTS(b1Record.CreatedAt),
|
||||
UpdatedAt: presenters.FormatTS(b1Record.UpdatedAt),
|
||||
},
|
||||
},
|
||||
CreatedAt: presenters.FormatTS(r1Record.CreatedAt),
|
||||
UpdatedAt: presenters.FormatTS(r1Record.UpdatedAt),
|
||||
},
|
||||
{
|
||||
UUID: r2Record.UUID,
|
||||
Title: r2Record.Title,
|
||||
Enabled: r2Record.Enabled,
|
||||
Hour: r2Record.Hour,
|
||||
Minute: r2Record.Minute,
|
||||
Frequency: r2Record.Frequency,
|
||||
BookDomain: r2Record.BookDomain,
|
||||
NoteCount: r2Record.NoteCount,
|
||||
LastActive: r2Record.LastActive,
|
||||
Books: []presenters.Book{},
|
||||
CreatedAt: presenters.FormatTS(r2Record.CreatedAt),
|
||||
UpdatedAt: presenters.FormatTS(r2Record.UpdatedAt),
|
||||
},
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
}
|
||||
|
||||
func TestCreateRepetitionRules(t *testing.T) {
|
||||
t.Run("all books", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
c := clock.NewMock()
|
||||
t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC)
|
||||
c.SetNow(t0)
|
||||
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: c,
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
// Execute
|
||||
dat := `{
|
||||
"title": "Rule 1",
|
||||
"enabled": true,
|
||||
"hour": 8,
|
||||
"minute": 30,
|
||||
"frequency": 604800000,
|
||||
"book_domain": "all",
|
||||
"book_uuids": [],
|
||||
"note_count": 20
|
||||
}`
|
||||
req := testutils.MakeReq(server.URL, "POST", "/repetition_rules", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
|
||||
|
||||
var ruleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
|
||||
assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch")
|
||||
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Preload("Books").First(&rule), "finding b1Record")
|
||||
|
||||
assert.NotEqual(t, rule.UUID, "", "rule UUID mismatch")
|
||||
assert.Equal(t, rule.Title, "Rule 1", "rule Title mismatch")
|
||||
assert.Equal(t, rule.Enabled, true, "rule Enabled mismatch")
|
||||
assert.Equal(t, rule.Hour, 8, "rule HourTitle mismatch")
|
||||
assert.Equal(t, rule.Minute, 30, "rule Minute mismatch")
|
||||
assert.Equal(t, rule.Frequency, int64(604800000), "rule Frequency mismatch")
|
||||
assert.Equal(t, rule.LastActive, int64(0), "rule LastActive mismatch")
|
||||
assert.Equal(t, rule.NextActive, int64(1257064200000+604800000), "rule NextActive mismatch")
|
||||
assert.Equal(t, rule.BookDomain, "all", "rule BookDomain mismatch")
|
||||
assert.DeepEqual(t, rule.Books, []database.Book{}, "rule Books mismatch")
|
||||
assert.Equal(t, rule.NoteCount, 20, "rule NoteCount mismatch")
|
||||
})
|
||||
|
||||
bookDomainTestCases := []string{
|
||||
"including",
|
||||
"excluding",
|
||||
}
|
||||
for _, tc := range bookDomainTestCases {
|
||||
t.Run(tc, func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
c := clock.NewMock()
|
||||
t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC)
|
||||
c.SetNow(t0)
|
||||
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: c,
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
|
||||
// Execute
|
||||
dat := fmt.Sprintf(`{
|
||||
"title": "Rule 1",
|
||||
"enabled": true,
|
||||
"hour": 8,
|
||||
"minute": 30,
|
||||
"frequency": 604800000,
|
||||
"book_domain": "%s",
|
||||
"book_uuids": ["%s"],
|
||||
"note_count": 20
|
||||
}`, tc, b1.UUID)
|
||||
req := testutils.MakeReq(server.URL, "POST", "/repetition_rules", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
|
||||
|
||||
var ruleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
|
||||
assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch")
|
||||
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Preload("Books").First(&rule), "finding b1Record")
|
||||
|
||||
var b1Record database.Book
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&b1Record), "finding b1Record")
|
||||
|
||||
assert.NotEqual(t, rule.UUID, "", "rule UUID mismatch")
|
||||
assert.Equal(t, rule.Title, "Rule 1", "rule Title mismatch")
|
||||
assert.Equal(t, rule.Enabled, true, "rule Enabled mismatch")
|
||||
assert.Equal(t, rule.Hour, 8, "rule HourTitle mismatch")
|
||||
assert.Equal(t, rule.Minute, 30, "rule Minute mismatch")
|
||||
assert.Equal(t, rule.LastActive, int64(0), "rule LastActive mismatch")
|
||||
assert.Equal(t, rule.NextActive, int64(1257064200000+604800000), "rule NextActive mismatch")
|
||||
assert.Equal(t, rule.Frequency, int64(604800000), "rule Frequency mismatch")
|
||||
assert.Equal(t, rule.BookDomain, tc, "rule BookDomain mismatch")
|
||||
assert.DeepEqual(t, rule.Books, []database.Book{b1Record}, "rule Books mismatch")
|
||||
assert.Equal(t, rule.NoteCount, 20, "rule NoteCount mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRepetitionRules(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
c := clock.NewMock()
|
||||
t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC)
|
||||
c.SetNow(t0)
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: c,
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
// Execute
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
UserID: user.ID,
|
||||
Enabled: false,
|
||||
Hour: 8,
|
||||
Minute: 30,
|
||||
Frequency: 604800000,
|
||||
LastActive: 1257064200000,
|
||||
NextActive: 1263088980000,
|
||||
BookDomain: "all",
|
||||
Books: []database.Book{},
|
||||
NoteCount: 20,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing r1")
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
USN: 11,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing book1")
|
||||
|
||||
dat := fmt.Sprintf(`{
|
||||
"title": "Rule 1 - edited",
|
||||
"enabled": true,
|
||||
"hour": 18,
|
||||
"minute": 40,
|
||||
"frequency": 259200000,
|
||||
"book_domain": "including",
|
||||
"book_uuids": ["%s"],
|
||||
"note_count": 30
|
||||
}`, b1.UUID)
|
||||
endpoint := fmt.Sprintf("/repetition_rules/%s", r1.UUID)
|
||||
req := testutils.MakeReq(server.URL, "PATCH", endpoint, dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var totalRuleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&totalRuleCount), "counting rules")
|
||||
assert.Equalf(t, totalRuleCount, 1, "reperition rule count mismatch")
|
||||
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Preload("Books").First(&rule), "finding b1Record")
|
||||
|
||||
var b1Record database.Book
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&b1Record), "finding b1Record")
|
||||
|
||||
assert.NotEqual(t, rule.UUID, "", "rule UUID mismatch")
|
||||
assert.Equal(t, rule.Title, "Rule 1 - edited", "rule Title mismatch")
|
||||
assert.Equal(t, rule.Enabled, true, "rule Enabled mismatch")
|
||||
assert.Equal(t, rule.Hour, 18, "rule HourTitle mismatch")
|
||||
assert.Equal(t, rule.Minute, 40, "rule Minute mismatch")
|
||||
assert.Equal(t, rule.Frequency, int64(259200000), "rule Frequency mismatch")
|
||||
assert.Equal(t, rule.LastActive, int64(1257064200000), "rule LastActive mismatch")
|
||||
assert.Equal(t, rule.NextActive, int64(1257100800000+259200000), "rule NextActive mismatch")
|
||||
assert.Equal(t, rule.BookDomain, "including", "rule BookDomain mismatch")
|
||||
assert.DeepEqual(t, rule.Books, []database.Book{b1Record}, "rule Books mismatch")
|
||||
assert.Equal(t, rule.NoteCount, 30, "rule NoteCount mismatch")
|
||||
}
|
||||
|
||||
func TestDeleteRepetitionRules(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
// Execute
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
UserID: user.ID,
|
||||
Enabled: true,
|
||||
Hour: 8,
|
||||
Minute: 30,
|
||||
Frequency: 604800000,
|
||||
BookDomain: "all",
|
||||
Books: []database.Book{},
|
||||
NoteCount: 20,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing r1")
|
||||
|
||||
r2 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
UserID: user.ID,
|
||||
Enabled: true,
|
||||
Hour: 8,
|
||||
Minute: 30,
|
||||
Frequency: 604800000,
|
||||
BookDomain: "all",
|
||||
Books: []database.Book{},
|
||||
NoteCount: 20,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r2), "preparing r2")
|
||||
|
||||
endpoint := fmt.Sprintf("/repetition_rules/%s", r1.UUID)
|
||||
req := testutils.MakeReq(server.URL, "DELETE", endpoint, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var totalRuleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&totalRuleCount), "counting rules")
|
||||
assert.Equalf(t, totalRuleCount, 1, "reperition rule count mismatch")
|
||||
|
||||
var r2Count int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Where("id = ?", r2.ID).Count(&r2Count), "counting r2")
|
||||
assert.Equalf(t, r2Count, 1, "r2 count mismatch")
|
||||
}
|
||||
|
||||
func TestCreateUpdateRepetitionRules_BadRequest(t *testing.T) {
|
||||
testCases := []string{
|
||||
// empty title
|
||||
`{
|
||||
"title": "",
|
||||
"enabled": true,
|
||||
"hour": 8,
|
||||
"minute": 30,
|
||||
"frequency": 604800000,
|
||||
"book_domain": "all",
|
||||
"book_uuids": [],
|
||||
"note_count": 20
|
||||
}`,
|
||||
// empty frequency
|
||||
`{
|
||||
"title": "Rule 1",
|
||||
"enabled": true,
|
||||
"hour": 8,
|
||||
"minute": 30,
|
||||
"frequency": 0,
|
||||
"book_domain": "some_invalid_book_domain",
|
||||
"book_uuids": [],
|
||||
"note_count": 20
|
||||
}`,
|
||||
// empty note count
|
||||
`{
|
||||
"title": "Rule 1",
|
||||
"enabled": true,
|
||||
"hour": 8,
|
||||
"minute": 30,
|
||||
"frequency": 604800000,
|
||||
"book_domain": "all",
|
||||
"book_uuids": [],
|
||||
"note_count": 0
|
||||
}`,
|
||||
// invalid book doamin
|
||||
`{
|
||||
"title": "Rule 1",
|
||||
"enabled": true,
|
||||
"hour": 8,
|
||||
"minute": 30,
|
||||
"frequency": 604800000,
|
||||
"book_domain": "some_invalid_book_domain",
|
||||
"book_uuids": [],
|
||||
"note_count": 20
|
||||
}`,
|
||||
// invalid combination of book domain and book_uuids
|
||||
`{
|
||||
"title": "Rule 1",
|
||||
"enabled": true,
|
||||
"hour": 8,
|
||||
"minute": 30,
|
||||
"frequency": 604800000,
|
||||
"book_domain": "excluding",
|
||||
"book_uuids": [],
|
||||
"note_count": 20
|
||||
}`,
|
||||
`{
|
||||
"title": "Rule 1",
|
||||
"enabled": true,
|
||||
"hour": 8,
|
||||
"minute": 30,
|
||||
"frequency": 604800000,
|
||||
"book_domain": "including",
|
||||
"book_uuids": [],
|
||||
"note_count": 20
|
||||
}`,
|
||||
}
|
||||
|
||||
for idx, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("test case - create %d", idx), func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "POST", "/repetition_rules", tc)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
|
||||
|
||||
var ruleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
|
||||
assert.Equalf(t, ruleCount, 0, "reperition rule count mismatch")
|
||||
})
|
||||
|
||||
t.Run(fmt.Sprintf("test case %d - update", idx), func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
user := testutils.SetupUserData()
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
UserID: user.ID,
|
||||
Enabled: false,
|
||||
Hour: 8,
|
||||
Minute: 30,
|
||||
Frequency: 604800000,
|
||||
BookDomain: "all",
|
||||
Books: []database.Book{},
|
||||
NoteCount: 20,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing r1")
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
USN: 11,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing book1")
|
||||
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "PATCH", fmt.Sprintf("/repetition_rules/%s", r1.UUID), tc)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
|
||||
|
||||
var ruleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
|
||||
assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRepetitionRules_BadRequest(t *testing.T) {
|
||||
testCases := []string{
|
||||
// no enabeld field
|
||||
`{
|
||||
"title": "Rule #1",
|
||||
"hour": 8,
|
||||
"minute": 30,
|
||||
"frequency": 604800000,
|
||||
"book_domain": "all",
|
||||
"book_uuids": [],
|
||||
"note_count": 20
|
||||
}`,
|
||||
}
|
||||
|
||||
for idx, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "POST", "/repetition_rules", tc)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
|
||||
|
||||
var ruleCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules")
|
||||
assert.Equalf(t, ruleCount, 0, "reperition rule count mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -350,16 +350,6 @@ func (a *API) NewRouter() (*mux.Router, error) {
|
|||
{"PATCH", "/stripe_source", a.auth(a.updateStripeSource, nil), true},
|
||||
{"GET", "/notes", a.auth(a.getNotes, nil), false},
|
||||
{"GET", "/notes/{noteUUID}", a.getNote, true},
|
||||
{"GET", "/calendar", a.auth(a.getCalendar, nil), true},
|
||||
{"GET", "/repetition_rules", a.auth(a.getRepetitionRules, nil), true},
|
||||
{"GET", "/repetition_rules/{repetitionRuleUUID}", a.tokenAuth(a.getRepetitionRule, database.TokenTypeRepetition, &proOnly), true},
|
||||
{"POST", "/repetition_rules", a.auth(a.createRepetitionRule, &proOnly), true},
|
||||
{"PATCH", "/repetition_rules/{repetitionRuleUUID}", a.tokenAuth(a.updateRepetitionRule, database.TokenTypeRepetition, &proOnly), true},
|
||||
{"DELETE", "/repetition_rules/{repetitionRuleUUID}", a.auth(a.deleteRepetitionRule, &proOnly), true},
|
||||
{"GET", "/digests/{digestUUID}", a.auth(a.getDigest, nil), true},
|
||||
{"GET", "/digests", a.auth(a.getDigests, nil), true},
|
||||
{"POST", "/note_review", a.auth(a.createNoteReview, nil), true},
|
||||
{"DELETE", "/note_review", a.auth(a.deleteNoteReview, nil), true},
|
||||
|
||||
// migration of classic users
|
||||
{"GET", "/classic/presignin", cors(a.classicPresignin), true},
|
||||
|
|
|
|||
|
|
@ -240,16 +240,7 @@ func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type emailPreferernceParams struct {
|
||||
InactiveReminder *bool `json:"inactive_reminder"`
|
||||
ProductUpdate *bool `json:"product_update"`
|
||||
}
|
||||
|
||||
func (p emailPreferernceParams) getInactiveReminder() bool {
|
||||
if p.InactiveReminder == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return *p.InactiveReminder
|
||||
ProductUpdate *bool `json:"product_update"`
|
||||
}
|
||||
|
||||
func (p emailPreferernceParams) getProductUpdate() bool {
|
||||
|
|
@ -281,9 +272,6 @@ func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
tx := a.App.DB.Begin()
|
||||
|
||||
if params.InactiveReminder != nil {
|
||||
pref.InactiveReminder = params.getInactiveReminder()
|
||||
}
|
||||
if params.ProductUpdate != nil {
|
||||
pref.ProductUpdate = params.getProductUpdate()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ package handlers
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -393,241 +392,241 @@ func TestUpdateEmail(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestUpdateEmailPreference(t *testing.T) {
|
||||
t.Run("with login", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, false)
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": true}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, u)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding account")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "preference mismatch")
|
||||
})
|
||||
|
||||
t.Run("with an unused token", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, false)
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": true}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
var preferenceCount int
|
||||
var token database.Token
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
testutils.MustExec(t, testutils.DB.Model(database.EmailPreference{}).Count(&preferenceCount), "counting preference")
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", tok.ID).First(&token), "failed to find token")
|
||||
|
||||
assert.Equal(t, preferenceCount, 1, "preference count mismatch")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used")
|
||||
})
|
||||
|
||||
t.Run("with nonexistent token", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, true)
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
dat := `{"inactive_reminder": false}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someNonexistentToken")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
})
|
||||
|
||||
t.Run("with expired token", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, true)
|
||||
|
||||
usedAt := time.Now().Add(-11 * time.Minute)
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
UsedAt: &usedAt,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": false}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
})
|
||||
|
||||
t.Run("with a used but unexpired token", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, true)
|
||||
usedAt := time.Now().Add(-9 * time.Minute)
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
UsedAt: &usedAt,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
dat := `{"inactive_reminder": false}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, false, "InactiveReminder mismatch")
|
||||
})
|
||||
|
||||
t.Run("no user and no token", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, true)
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": false}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
})
|
||||
|
||||
t.Run("create a record if not exists", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": false}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var preferenceCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(database.EmailPreference{}).Count(&preferenceCount), "counting preference")
|
||||
assert.Equal(t, preferenceCount, 1, "preference count mismatch")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, false, "email mismatch")
|
||||
})
|
||||
}
|
||||
// func TestUpdateEmailPreference(t *testing.T) {
|
||||
// t.Run("with login", func(t *testing.T) {
|
||||
// defer testutils.ClearData()
|
||||
//
|
||||
// // Setup
|
||||
// server := MustNewServer(t, &app.App{
|
||||
// Clock: clock.NewMock(),
|
||||
// })
|
||||
// defer server.Close()
|
||||
//
|
||||
// u := testutils.SetupUserData()
|
||||
// testutils.SetupEmailPreferenceData(u, false)
|
||||
//
|
||||
// // Execute
|
||||
// dat := `{"inactive_reminder": true}`
|
||||
// req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
|
||||
// res := testutils.HTTPAuthDo(t, req, u)
|
||||
//
|
||||
// // Test
|
||||
// assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
//
|
||||
// var preference database.EmailPreference
|
||||
// testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding account")
|
||||
// assert.Equal(t, preference.InactiveReminder, true, "preference mismatch")
|
||||
// })
|
||||
//
|
||||
// t.Run("with an unused token", func(t *testing.T) {
|
||||
// defer testutils.ClearData()
|
||||
//
|
||||
// // Setup
|
||||
// server := MustNewServer(t, &app.App{
|
||||
// Clock: clock.NewMock(),
|
||||
// })
|
||||
// defer server.Close()
|
||||
//
|
||||
// u := testutils.SetupUserData()
|
||||
// testutils.SetupEmailPreferenceData(u, false)
|
||||
// tok := database.Token{
|
||||
// UserID: u.ID,
|
||||
// Type: database.TokenTypeEmailPreference,
|
||||
// Value: "someTokenValue",
|
||||
// }
|
||||
// testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
//
|
||||
// // Execute
|
||||
// dat := `{"inactive_reminder": true}`
|
||||
// url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
// req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
// res := testutils.HTTPDo(t, req)
|
||||
//
|
||||
// // Test
|
||||
// assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
//
|
||||
// var preference database.EmailPreference
|
||||
// var preferenceCount int
|
||||
// var token database.Token
|
||||
// testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
// testutils.MustExec(t, testutils.DB.Model(database.EmailPreference{}).Count(&preferenceCount), "counting preference")
|
||||
// testutils.MustExec(t, testutils.DB.Where("id = ?", tok.ID).First(&token), "failed to find token")
|
||||
//
|
||||
// assert.Equal(t, preferenceCount, 1, "preference count mismatch")
|
||||
// assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
// assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used")
|
||||
// })
|
||||
//
|
||||
// t.Run("with nonexistent token", func(t *testing.T) {
|
||||
// defer testutils.ClearData()
|
||||
//
|
||||
// // Setup
|
||||
// server := MustNewServer(t, &app.App{
|
||||
// Clock: clock.NewMock(),
|
||||
// })
|
||||
// defer server.Close()
|
||||
//
|
||||
// u := testutils.SetupUserData()
|
||||
// testutils.SetupEmailPreferenceData(u, true)
|
||||
// tok := database.Token{
|
||||
// UserID: u.ID,
|
||||
// Type: database.TokenTypeEmailPreference,
|
||||
// Value: "someTokenValue",
|
||||
// }
|
||||
// testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
//
|
||||
// dat := `{"inactive_reminder": false}`
|
||||
// url := fmt.Sprintf("/account/email-preference?token=%s", "someNonexistentToken")
|
||||
// req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
//
|
||||
// // Execute
|
||||
// res := testutils.HTTPDo(t, req)
|
||||
//
|
||||
// // Test
|
||||
// assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
//
|
||||
// var preference database.EmailPreference
|
||||
// testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
// assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
// })
|
||||
//
|
||||
// t.Run("with expired token", func(t *testing.T) {
|
||||
// defer testutils.ClearData()
|
||||
//
|
||||
// // Setup
|
||||
// server := MustNewServer(t, &app.App{
|
||||
//
|
||||
// Clock: clock.NewMock(),
|
||||
// })
|
||||
// defer server.Close()
|
||||
//
|
||||
// u := testutils.SetupUserData()
|
||||
// testutils.SetupEmailPreferenceData(u, true)
|
||||
//
|
||||
// usedAt := time.Now().Add(-11 * time.Minute)
|
||||
// tok := database.Token{
|
||||
// UserID: u.ID,
|
||||
// Type: database.TokenTypeEmailPreference,
|
||||
// Value: "someTokenValue",
|
||||
// UsedAt: &usedAt,
|
||||
// }
|
||||
// testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
//
|
||||
// // Execute
|
||||
// dat := `{"inactive_reminder": false}`
|
||||
// url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
// req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
// res := testutils.HTTPDo(t, req)
|
||||
//
|
||||
// // Test
|
||||
// assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
//
|
||||
// var preference database.EmailPreference
|
||||
// testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
// assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
// })
|
||||
//
|
||||
// t.Run("with a used but unexpired token", func(t *testing.T) {
|
||||
//
|
||||
// defer testutils.ClearData()
|
||||
//
|
||||
// // Setup
|
||||
// server := MustNewServer(t, &app.App{
|
||||
//
|
||||
// Clock: clock.NewMock(),
|
||||
// })
|
||||
// defer server.Close()
|
||||
//
|
||||
// u := testutils.SetupUserData()
|
||||
// testutils.SetupEmailPreferenceData(u, true)
|
||||
// usedAt := time.Now().Add(-9 * time.Minute)
|
||||
// tok := database.Token{
|
||||
// UserID: u.ID,
|
||||
// Type: database.TokenTypeEmailPreference,
|
||||
// Value: "someTokenValue",
|
||||
// UsedAt: &usedAt,
|
||||
// }
|
||||
// testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
//
|
||||
// dat := `{"inactive_reminder": false}`
|
||||
// url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
// req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
//
|
||||
// // Execute
|
||||
// res := testutils.HTTPDo(t, req)
|
||||
//
|
||||
// // Test
|
||||
// assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
//
|
||||
// var preference database.EmailPreference
|
||||
// testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
// assert.Equal(t, preference.InactiveReminder, false, "InactiveReminder mismatch")
|
||||
// })
|
||||
//
|
||||
// t.Run("no user and no token", func(t *testing.T) {
|
||||
//
|
||||
// defer testutils.ClearData()
|
||||
//
|
||||
// // Setup
|
||||
// server := MustNewServer(t, &app.App{
|
||||
//
|
||||
// Clock: clock.NewMock(),
|
||||
// })
|
||||
// defer server.Close()
|
||||
//
|
||||
// u := testutils.SetupUserData()
|
||||
// testutils.SetupEmailPreferenceData(u, true)
|
||||
//
|
||||
// // Execute
|
||||
// dat := `{"inactive_reminder": false}`
|
||||
// req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
|
||||
// res := testutils.HTTPDo(t, req)
|
||||
//
|
||||
// // Test
|
||||
// assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
//
|
||||
// var preference database.EmailPreference
|
||||
// testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
// assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
// })
|
||||
//
|
||||
// t.Run("create a record if not exists", func(t *testing.T) {
|
||||
//
|
||||
// defer testutils.ClearData()
|
||||
//
|
||||
// // Setup
|
||||
// server := MustNewServer(t, &app.App{
|
||||
//
|
||||
// Clock: clock.NewMock(),
|
||||
// })
|
||||
// defer server.Close()
|
||||
//
|
||||
// u := testutils.SetupUserData()
|
||||
// tok := database.Token{
|
||||
// UserID: u.ID,
|
||||
// Type: database.TokenTypeEmailPreference,
|
||||
// Value: "someTokenValue",
|
||||
// }
|
||||
// testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
//
|
||||
// // Execute
|
||||
// dat := `{"inactive_reminder": false}`
|
||||
// url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
// req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
// res := testutils.HTTPDo(t, req)
|
||||
//
|
||||
// // Test
|
||||
// assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
//
|
||||
// var preferenceCount int
|
||||
// testutils.MustExec(t, testutils.DB.Model(database.EmailPreference{}).Count(&preferenceCount), "counting preference")
|
||||
// assert.Equal(t, preferenceCount, 1, "preference count mismatch")
|
||||
//
|
||||
// var preference database.EmailPreference
|
||||
// testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
// assert.Equal(t, preference.InactiveReminder, false, "email mismatch")
|
||||
// })
|
||||
// }
|
||||
|
||||
func TestGetEmailPreference(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
|
@ -654,10 +653,9 @@ func TestGetEmailPreference(t *testing.T) {
|
|||
}
|
||||
|
||||
expected := presenters.EmailPreference{
|
||||
InactiveReminder: pref.InactiveReminder,
|
||||
ProductUpdate: pref.ProductUpdate,
|
||||
CreatedAt: presenters.FormatTS(pref.CreatedAt),
|
||||
UpdatedAt: presenters.FormatTS(pref.UpdatedAt),
|
||||
ProductUpdate: pref.ProductUpdate,
|
||||
CreatedAt: presenters.FormatTS(pref.CreatedAt),
|
||||
UpdatedAt: presenters.FormatTS(pref.UpdatedAt),
|
||||
}
|
||||
assert.DeepEqual(t, got, expected, "payload mismatch")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package job
|
||||
|
||||
import (
|
||||
slog "log"
|
||||
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/job/remind"
|
||||
"github.com/dnote/dnote/pkg/server/job/repetition"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/robfig/cron"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEmptyDB is an error for missing database connection in the app configuration
|
||||
ErrEmptyDB = errors.New("No database connection was provided")
|
||||
// ErrEmptyClock is an error for missing clock in the app configuration
|
||||
ErrEmptyClock = errors.New("No clock was provided")
|
||||
// ErrEmptyWebURL is an error for missing WebURL content in the app configuration
|
||||
ErrEmptyWebURL = errors.New("No WebURL was provided")
|
||||
// ErrEmptyEmailTemplates is an error for missing EmailTemplates content in the app configuration
|
||||
ErrEmptyEmailTemplates = errors.New("No EmailTemplate store was provided")
|
||||
// ErrEmptyEmailBackend is an error for missing EmailBackend content in the app configuration
|
||||
ErrEmptyEmailBackend = errors.New("No EmailBackend was provided")
|
||||
)
|
||||
|
||||
// Runner is a configuration for job
|
||||
type Runner struct {
|
||||
DB *gorm.DB
|
||||
Clock clock.Clock
|
||||
EmailTmpl mailer.Templates
|
||||
EmailBackend mailer.Backend
|
||||
Config app.Config
|
||||
}
|
||||
|
||||
// NewRunner returns a new runner
|
||||
func NewRunner(db *gorm.DB, c clock.Clock, t mailer.Templates, b mailer.Backend, config app.Config) (Runner, error) {
|
||||
ret := Runner{
|
||||
DB: db,
|
||||
EmailTmpl: t,
|
||||
EmailBackend: b,
|
||||
Clock: c,
|
||||
Config: config,
|
||||
}
|
||||
|
||||
if err := ret.validate(); err != nil {
|
||||
return Runner{}, errors.Wrap(err, "validating runner configuration")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *Runner) validate() error {
|
||||
if r.DB == nil {
|
||||
return ErrEmptyDB
|
||||
}
|
||||
if r.Clock == nil {
|
||||
return ErrEmptyClock
|
||||
}
|
||||
if r.EmailTmpl == nil {
|
||||
return ErrEmptyEmailTemplates
|
||||
}
|
||||
if r.EmailBackend == nil {
|
||||
return ErrEmptyEmailBackend
|
||||
}
|
||||
if r.Config.WebURL == "" {
|
||||
return ErrEmptyWebURL
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scheduleJob(c *cron.Cron, spec string, cmd func()) {
|
||||
s, err := cron.ParseStandard(spec)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "parsing schedule"))
|
||||
}
|
||||
|
||||
c.Schedule(s, cron.FuncJob(cmd))
|
||||
}
|
||||
|
||||
func (r *Runner) schedule(ch chan error) {
|
||||
// Schedule jobs
|
||||
cr := cron.New()
|
||||
scheduleJob(cr, "* * * * *", func() { r.DoRepetition() })
|
||||
scheduleJob(cr, "0 8 * * *", func() { r.RemindNoRecentNotes() })
|
||||
cr.Start()
|
||||
|
||||
ch <- nil
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// Do starts the background tasks in a separate goroutine that runs forever
|
||||
func (r *Runner) Do() error {
|
||||
// validate
|
||||
if err := r.validate(); err != nil {
|
||||
return errors.Wrap(err, "validating job configurations")
|
||||
}
|
||||
|
||||
ch := make(chan error)
|
||||
go r.schedule(ch)
|
||||
if err := <-ch; err != nil {
|
||||
return errors.Wrap(err, "scheduling jobs")
|
||||
}
|
||||
|
||||
slog.Println("Started background tasks")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DoRepetition creates spaced repetitions and delivers the results based on the rules
|
||||
func (r *Runner) DoRepetition() {
|
||||
c := repetition.Context{
|
||||
DB: r.DB,
|
||||
Clock: r.Clock,
|
||||
EmailTmpl: r.EmailTmpl,
|
||||
EmailBackend: r.EmailBackend,
|
||||
Config: r.Config,
|
||||
}
|
||||
|
||||
result, err := repetition.Do(c)
|
||||
m := log.WithFields(log.Fields{
|
||||
"success_count": result.SuccessCount,
|
||||
"failed_rule_uuids": result.FailedRuleUUIDs,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
m.Info("successfully processed repetition job")
|
||||
} else {
|
||||
m.ErrorWrap(err, "error processing repetition job")
|
||||
}
|
||||
}
|
||||
|
||||
// RemindNoRecentNotes remind users if no notes have been added recently
|
||||
func (r *Runner) RemindNoRecentNotes() {
|
||||
c := remind.Context{
|
||||
DB: r.DB,
|
||||
Clock: r.Clock,
|
||||
EmailTmpl: r.EmailTmpl,
|
||||
EmailBackend: r.EmailBackend,
|
||||
Config: r.Config,
|
||||
}
|
||||
|
||||
result, err := remind.DoInactive(c)
|
||||
m := log.WithFields(log.Fields{
|
||||
"success_count": result.SuccessCount,
|
||||
"failed_user_ids": result.FailedUserIDs,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
m.Info("successfully processed no recent note reminder job")
|
||||
} else {
|
||||
m.ErrorWrap(err, "error processing no recent note reminder job")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package job
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestNewRunner(t *testing.T) {
|
||||
testCases := []struct {
|
||||
db *gorm.DB
|
||||
clock clock.Clock
|
||||
emailTmpl mailer.Templates
|
||||
emailBackend mailer.Backend
|
||||
webURL string
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
db: &gorm.DB{},
|
||||
clock: clock.NewMock(),
|
||||
emailTmpl: mailer.Templates{},
|
||||
emailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
webURL: "http://mock.url",
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
db: nil,
|
||||
clock: clock.NewMock(),
|
||||
emailTmpl: mailer.Templates{},
|
||||
emailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
webURL: "http://mock.url",
|
||||
expectedErr: ErrEmptyDB,
|
||||
},
|
||||
{
|
||||
db: &gorm.DB{},
|
||||
clock: nil,
|
||||
emailTmpl: mailer.Templates{},
|
||||
emailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
webURL: "http://mock.url",
|
||||
expectedErr: ErrEmptyClock,
|
||||
},
|
||||
{
|
||||
db: &gorm.DB{},
|
||||
clock: clock.NewMock(),
|
||||
emailTmpl: nil,
|
||||
emailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
webURL: "http://mock.url",
|
||||
expectedErr: ErrEmptyEmailTemplates,
|
||||
},
|
||||
{
|
||||
db: &gorm.DB{},
|
||||
clock: clock.NewMock(),
|
||||
emailTmpl: mailer.Templates{},
|
||||
emailBackend: nil,
|
||||
webURL: "http://mock.url",
|
||||
expectedErr: ErrEmptyEmailBackend,
|
||||
},
|
||||
{
|
||||
db: &gorm.DB{},
|
||||
clock: clock.NewMock(),
|
||||
emailTmpl: mailer.Templates{},
|
||||
emailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
webURL: "",
|
||||
expectedErr: ErrEmptyWebURL,
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
|
||||
_, err := NewRunner(tc.db, tc.clock, tc.emailTmpl, tc.emailBackend, app.Config{
|
||||
WebURL: tc.webURL,
|
||||
})
|
||||
|
||||
assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package remind
|
||||
|
||||
import (
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Context holds data that repetition job needs in order to perform
|
||||
type Context struct {
|
||||
DB *gorm.DB
|
||||
Clock clock.Clock
|
||||
EmailTmpl mailer.Templates
|
||||
EmailBackend mailer.Backend
|
||||
Config app.Config
|
||||
}
|
||||
|
||||
type inactiveUserInfo struct {
|
||||
userID int
|
||||
email string
|
||||
sampleNoteUUID string
|
||||
}
|
||||
|
||||
func (c *Context) sampleUserNote(userID int) (database.Note, error) {
|
||||
var ret database.Note
|
||||
// FIXME: ordering by random() requires a sequential scan on the whole table and does not scale
|
||||
if err := c.DB.Where("user_id = ?", userID).Order("random() DESC").First(&ret).Error; err != nil {
|
||||
return ret, errors.Wrap(err, "getting a random note")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (c *Context) getInactiveUserInfo() ([]inactiveUserInfo, error) {
|
||||
ret := []inactiveUserInfo{}
|
||||
|
||||
threshold := c.Clock.Now().AddDate(0, 0, -14).Unix()
|
||||
|
||||
rows, err := c.DB.Raw(`
|
||||
SELECT
|
||||
notes.user_id AS user_id,
|
||||
accounts.email,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN notes.created_at > to_timestamp(?) THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
) AS recent_note_count,
|
||||
COUNT(*) AS total_note_count
|
||||
FROM notes
|
||||
INNER JOIN accounts ON accounts.user_id = notes.user_id
|
||||
WHERE accounts.email IS NOT NULL AND accounts.email_verified IS TRUE
|
||||
GROUP BY notes.user_id, accounts.email`, threshold).Rows()
|
||||
if err != nil {
|
||||
return ret, errors.Wrap(err, "executing note count SQL query")
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var userID, recentNoteCount, totalNoteCount int
|
||||
var email string
|
||||
if err := rows.Scan(&userID, &email, &recentNoteCount, &totalNoteCount); err != nil {
|
||||
return nil, errors.Wrap(err, "scanning a row")
|
||||
}
|
||||
|
||||
if recentNoteCount == 0 && totalNoteCount > 0 {
|
||||
note, err := c.sampleUserNote(userID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "sampling user note")
|
||||
}
|
||||
|
||||
ret = append(ret, inactiveUserInfo{
|
||||
userID: userID,
|
||||
email: email,
|
||||
sampleNoteUUID: note.UUID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (c *Context) canNotify(info inactiveUserInfo) (bool, error) {
|
||||
var pref database.EmailPreference
|
||||
if err := c.DB.Where("user_id = ?", info.userID).First(&pref).Error; err != nil {
|
||||
return false, errors.Wrap(err, "getting email preference")
|
||||
}
|
||||
|
||||
if !pref.InactiveReminder {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var notif database.Notification
|
||||
conn := c.DB.Where("user_id = ? AND type = ?", info.userID, mailer.EmailTypeInactiveReminder).Order("created_at DESC").First(¬if)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
return true, nil
|
||||
} else if err := conn.Error; err != nil {
|
||||
return false, errors.Wrap(err, "checking cooldown")
|
||||
}
|
||||
|
||||
t := c.Clock.Now().AddDate(0, 0, -14)
|
||||
if notif.CreatedAt.Before(t) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (c *Context) process(info inactiveUserInfo) error {
|
||||
ok, err := c.canNotify(info)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "checking if user can be notified")
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
sender, err := c.Config.GetSenderEmail("noreply@getdnote.com")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting sender email")
|
||||
}
|
||||
|
||||
tmplData := mailer.InactiveReminderTmplData{
|
||||
WebURL: c.Config.WebURL,
|
||||
SampleNoteUUID: info.sampleNoteUUID,
|
||||
Token: "blah",
|
||||
}
|
||||
body, err := c.EmailTmpl.Execute(mailer.EmailTypeInactiveReminder, mailer.EmailKindText, tmplData)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "executing inactive email template")
|
||||
}
|
||||
|
||||
if err := c.EmailBackend.Queue("Your knowledge base stopped growing", sender, []string{info.email}, mailer.EmailKindText, body); err != nil {
|
||||
return errors.Wrap(err, "queueing email")
|
||||
}
|
||||
|
||||
if err := c.DB.Create(&database.Notification{
|
||||
Type: mailer.EmailTypeInactiveReminder,
|
||||
UserID: info.userID,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(err, "creating notification")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Result holds the result of the job
|
||||
type Result struct {
|
||||
SuccessCount int
|
||||
FailedUserIDs []int
|
||||
}
|
||||
|
||||
// DoInactive sends reminder for users with no recent notes
|
||||
func DoInactive(c Context) (Result, error) {
|
||||
log.Info("performing reminder for no recent notes")
|
||||
|
||||
result := Result{}
|
||||
items, err := c.getInactiveUserInfo()
|
||||
if err != nil {
|
||||
return result, errors.Wrap(err, "getting inactive user information")
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"user_count": len(items),
|
||||
}).Info("counted inactive users")
|
||||
|
||||
for _, item := range items {
|
||||
err := c.process(item)
|
||||
|
||||
if err == nil {
|
||||
result.SuccessCount = result.SuccessCount + 1
|
||||
} else {
|
||||
log.WithFields(log.Fields{
|
||||
"user_id": item.userID,
|
||||
}).ErrorWrap(err, "Could not process no recent notes reminder")
|
||||
|
||||
result.FailedUserIDs = append(result.FailedUserIDs, item.userID)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package remind
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) Context {
|
||||
emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR")
|
||||
|
||||
con := Context{
|
||||
DB: testutils.DB,
|
||||
Clock: c,
|
||||
EmailTmpl: mailer.NewTemplates(&emailTmplDir),
|
||||
EmailBackend: be,
|
||||
}
|
||||
|
||||
return con
|
||||
}
|
||||
|
||||
func TestDoInactive(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
t1 := time.Now()
|
||||
|
||||
// u1 is an active user
|
||||
u1 := testutils.SetupUserData()
|
||||
a1 := testutils.SetupAccountData(u1, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a1).Update("email_verified", true), "setting email verified")
|
||||
testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u1.ID, InactiveReminder: true}), "preparing email preference")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: u1.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
n1 := database.Note{
|
||||
BookUUID: b1.UUID,
|
||||
UserID: u1.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
|
||||
// u2 is an inactive user
|
||||
u2 := testutils.SetupUserData()
|
||||
a2 := testutils.SetupAccountData(u2, "bob@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a2).Update("email_verified", true), "setting email verified")
|
||||
testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u2.ID, InactiveReminder: true}), "preparing email preference")
|
||||
|
||||
b2 := database.Book{
|
||||
UserID: u2.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
|
||||
n2 := database.Note{
|
||||
UserID: u2.ID,
|
||||
BookUUID: b2.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
|
||||
testutils.MustExec(t, testutils.DB.Model(&n2).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n2")
|
||||
|
||||
// u3 is an inactive user with inactive alert email preference disabled
|
||||
u3 := testutils.SetupUserData()
|
||||
a3 := testutils.SetupAccountData(u3, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a3).Update("email_verified", true), "setting email verified")
|
||||
emailPref3 := database.EmailPreference{UserID: u3.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&emailPref3), "preparing email preference")
|
||||
testutils.MustExec(t, testutils.DB.Model(&emailPref3).Update(map[string]interface{}{"inactive_reminder": false}), "updating email preference")
|
||||
|
||||
b3 := database.Book{
|
||||
UserID: u3.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
|
||||
n3 := database.Note{
|
||||
BookUUID: b3.UUID,
|
||||
UserID: u3.ID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
|
||||
testutils.MustExec(t, testutils.DB.Model(&n3).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n3")
|
||||
|
||||
c := clock.NewMock()
|
||||
c.SetNow(t1)
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
con := getTestContext(c, be)
|
||||
if _, err := DoInactive(con); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "performing"))
|
||||
}
|
||||
|
||||
assert.Equalf(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
assert.DeepEqual(t, be.Emails[0].To, []string{a2.Email.String}, "email address mismatch")
|
||||
}
|
||||
|
||||
func TestDoInactive_Cooldown(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// setup sets up an inactive user
|
||||
setup := func(t *testing.T, now time.Time, email string) database.User {
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, email, "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "setting email verified")
|
||||
testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u.ID, InactiveReminder: true}), "preparing email preference")
|
||||
|
||||
b := database.Book{
|
||||
UserID: u.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b), "preparing book")
|
||||
n := database.Note{
|
||||
UserID: u.ID,
|
||||
BookUUID: b.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n), "preparing note")
|
||||
testutils.MustExec(t, testutils.DB.Model(&n).Update("created_at", now.AddDate(0, 0, -15)), "preparing note")
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
// Set up
|
||||
now := time.Now()
|
||||
|
||||
setup(t, now, "alice@example.com")
|
||||
|
||||
bob := setup(t, now, "bob@example.com")
|
||||
bobNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: bob.ID}
|
||||
testutils.MustExec(t, testutils.DB.Create(&bobNotif), "preparing inactive notification for bob")
|
||||
testutils.MustExec(t, testutils.DB.Model(&bobNotif).Update("created_at", now.AddDate(0, 0, -7)), "preparing created_at for inactive notification for bob")
|
||||
|
||||
chuck := setup(t, now, "chuck@example.com")
|
||||
chuckNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: chuck.ID}
|
||||
testutils.MustExec(t, testutils.DB.Create(&chuckNotif), "preparing inactive notification for chuck")
|
||||
testutils.MustExec(t, testutils.DB.Model(&chuckNotif).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for chuck")
|
||||
|
||||
dan := setup(t, now, "dan@example.com")
|
||||
danNotif1 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID}
|
||||
testutils.MustExec(t, testutils.DB.Create(&danNotif1), "preparing inactive notification 1 for dan")
|
||||
testutils.MustExec(t, testutils.DB.Model(&danNotif1).Update("created_at", now.AddDate(0, 0, -10)), "preparing created_at for inactive notification for dan")
|
||||
danNotif2 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID}
|
||||
testutils.MustExec(t, testutils.DB.Create(&danNotif2), "preparing inactive notification 2 for dan")
|
||||
testutils.MustExec(t, testutils.DB.Model(&danNotif2).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for dan")
|
||||
|
||||
c := clock.NewMock()
|
||||
c.SetNow(now)
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
// Execute
|
||||
con := getTestContext(c, be)
|
||||
if _, err := DoInactive(con); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "performing"))
|
||||
}
|
||||
|
||||
// Test
|
||||
assert.Equalf(t, len(be.Emails), 2, "email queue count mismatch")
|
||||
|
||||
var recipients []string
|
||||
for _, email := range be.Emails {
|
||||
recipients = append(recipients, email.To[0])
|
||||
}
|
||||
sort.SliceStable(recipients, func(i, j int) bool {
|
||||
r1 := recipients[i]
|
||||
r2 := recipients[j]
|
||||
|
||||
return r1 < r2
|
||||
})
|
||||
|
||||
assert.DeepEqual(t, recipients, []string{"alice@example.com", "chuck@example.com"}, "email address mismatch")
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package remind
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package repetition
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testutils.InitTestDB()
|
||||
|
||||
code := m.Run()
|
||||
testutils.ClearData()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package repetition
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/operations"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Context holds data that repetition job needs in order to perform
|
||||
type Context struct {
|
||||
DB *gorm.DB
|
||||
Clock clock.Clock
|
||||
EmailTmpl mailer.Templates
|
||||
EmailBackend mailer.Backend
|
||||
Config app.Config
|
||||
}
|
||||
|
||||
// BuildEmailParams is the params for building an email
|
||||
type BuildEmailParams struct {
|
||||
Now time.Time
|
||||
User database.User
|
||||
Digest database.Digest
|
||||
Rule database.RepetitionRule
|
||||
}
|
||||
|
||||
// BuildEmail builds an email for the spaced repetition
|
||||
func BuildEmail(db *gorm.DB, emailTmpl mailer.Templates, p BuildEmailParams) (string, string, error) {
|
||||
subject := fmt.Sprintf("%s #%d", p.Rule.Title, p.Digest.Version)
|
||||
tok, err := mailer.GetToken(db, p.User, database.TokenTypeRepetition)
|
||||
if err != nil {
|
||||
return "", "", errors.Wrap(err, "getting email frequency token")
|
||||
}
|
||||
|
||||
t1 := p.Now.AddDate(0, 0, -3).UnixNano()
|
||||
t2 := p.Now.AddDate(0, 0, -7).UnixNano()
|
||||
|
||||
noteInfos := []mailer.DigestNoteInfo{}
|
||||
for _, note := range p.Digest.Notes {
|
||||
var stage int
|
||||
if note.AddedOn > t1 {
|
||||
stage = 1
|
||||
} else if note.AddedOn > t2 && note.AddedOn < t1 {
|
||||
stage = 2
|
||||
} else if note.AddedOn < t2 {
|
||||
stage = 3
|
||||
}
|
||||
|
||||
info := mailer.NewNoteInfo(note, stage)
|
||||
noteInfos = append(noteInfos, info)
|
||||
}
|
||||
|
||||
bookCount := 0
|
||||
bookMap := map[string]bool{}
|
||||
for _, n := range p.Digest.Notes {
|
||||
if ok := bookMap[n.Book.Label]; !ok {
|
||||
bookCount++
|
||||
bookMap[n.Book.Label] = true
|
||||
}
|
||||
}
|
||||
|
||||
tmplData := mailer.DigestTmplData{
|
||||
EmailSessionToken: tok.Value,
|
||||
DigestUUID: p.Digest.UUID,
|
||||
DigestVersion: p.Digest.Version,
|
||||
RuleUUID: p.Rule.UUID,
|
||||
RuleTitle: p.Rule.Title,
|
||||
WebURL: os.Getenv("WebURL"),
|
||||
}
|
||||
body, err := emailTmpl.Execute(mailer.EmailTypeDigest, mailer.EmailKindText, tmplData)
|
||||
if err != nil {
|
||||
return "", "", errors.Wrap(err, "executing digest email template")
|
||||
}
|
||||
|
||||
return subject, body, nil
|
||||
}
|
||||
|
||||
func (c Context) getEligibleRules(now time.Time) ([]database.RepetitionRule, error) {
|
||||
hour := now.Hour()
|
||||
minute := now.Minute()
|
||||
|
||||
var ret []database.RepetitionRule
|
||||
if err := c.DB.
|
||||
Where("users.cloud AND repetition_rules.hour = ? AND repetition_rules.minute = ? AND repetition_rules.enabled", hour, minute).
|
||||
Joins("INNER JOIN users ON users.id = repetition_rules.user_id").
|
||||
Find(&ret).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "querying db")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func build(tx *gorm.DB, rule database.RepetitionRule) (database.Digest, error) {
|
||||
notes, err := getBalancedNotes(tx, rule)
|
||||
if err != nil {
|
||||
return database.Digest{}, errors.Wrap(err, "getting notes")
|
||||
}
|
||||
|
||||
digest, err := operations.CreateDigest(tx, rule, notes)
|
||||
if err != nil {
|
||||
return database.Digest{}, errors.Wrap(err, "creating digest")
|
||||
}
|
||||
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
func (c Context) notify(now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error {
|
||||
var account database.Account
|
||||
if err := c.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
|
||||
return errors.Wrap(err, "getting account")
|
||||
}
|
||||
|
||||
if !account.Email.Valid || !account.EmailVerified {
|
||||
log.WithFields(log.Fields{
|
||||
"user_id": user.ID,
|
||||
}).Info("Skipping repetition delivery because email is not valid or verified")
|
||||
return nil
|
||||
}
|
||||
|
||||
subject, body, err := BuildEmail(c.DB, c.EmailTmpl, BuildEmailParams{
|
||||
Now: now,
|
||||
User: user,
|
||||
Digest: digest,
|
||||
Rule: rule,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "making email")
|
||||
}
|
||||
|
||||
sender, err := c.Config.GetSenderEmail("noreply@getdnote.com")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting sender email")
|
||||
}
|
||||
|
||||
if err := c.EmailBackend.Queue(subject, sender, []string{account.Email.String}, mailer.EmailKindText, body); err != nil {
|
||||
return errors.Wrap(err, "queueing email")
|
||||
}
|
||||
|
||||
if err := c.DB.Create(&database.Notification{
|
||||
Type: mailer.EmailTypeDigest,
|
||||
UserID: user.ID,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(err, "creating notification")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCooldown(now time.Time, rule database.RepetitionRule) bool {
|
||||
present := now.UnixNano() / int64(time.Millisecond)
|
||||
|
||||
return present >= rule.NextActive
|
||||
}
|
||||
|
||||
func getNextActive(base int64, frequency int64, now time.Time) int64 {
|
||||
candidate := base + frequency
|
||||
if candidate >= now.UnixNano()/int64(time.Millisecond) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
return getNextActive(candidate, frequency, now)
|
||||
}
|
||||
|
||||
func touchTimestamp(tx *gorm.DB, rule database.RepetitionRule, now time.Time) error {
|
||||
lastActive := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, now.Location()).UnixNano() / int64(time.Millisecond)
|
||||
|
||||
rule.LastActive = lastActive
|
||||
rule.NextActive = getNextActive(rule.LastActive, rule.Frequency, now)
|
||||
|
||||
if err := tx.Save(&rule).Error; err != nil {
|
||||
return errors.Wrap(err, "updating repetition rule")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Context) process(now time.Time, rule database.RepetitionRule) error {
|
||||
log.WithFields(log.Fields{
|
||||
"uuid": rule.UUID,
|
||||
}).Info("processing repetition")
|
||||
|
||||
tx := c.DB.Begin()
|
||||
|
||||
if !checkCooldown(now, rule) {
|
||||
log.WithFields(log.Fields{
|
||||
"uuid": rule.UUID,
|
||||
}).Info("Skipping repetition processing due to cooldown")
|
||||
return nil
|
||||
}
|
||||
|
||||
var user database.User
|
||||
if err := tx.Where("id = ?", rule.UserID).First(&user).Error; err != nil {
|
||||
return errors.Wrap(err, "getting user")
|
||||
}
|
||||
if !user.Cloud {
|
||||
log.WithFields(log.Fields{
|
||||
"user_id": user.ID,
|
||||
}).Info("Skipping repetition due to lack of subscription")
|
||||
return nil
|
||||
}
|
||||
|
||||
digest, err := build(tx, rule)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return errors.Wrap(err, "building repetition")
|
||||
}
|
||||
|
||||
if err := touchTimestamp(tx, rule, now); err != nil {
|
||||
tx.Rollback()
|
||||
return errors.Wrap(err, "touching last_active")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
tx.Rollback()
|
||||
return errors.Wrap(err, "committing transaction")
|
||||
}
|
||||
|
||||
if err := c.notify(now, user, digest, rule); err != nil {
|
||||
return errors.Wrap(err, "notifying user")
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"uuid": rule.UUID,
|
||||
}).Info("finished processing repetition")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Result holds the result of the job
|
||||
type Result struct {
|
||||
SuccessCount int
|
||||
FailedRuleUUIDs []string
|
||||
}
|
||||
|
||||
// Do creates spaced repetitions and delivers the results based on the rules
|
||||
func Do(c Context) (Result, error) {
|
||||
now := c.Clock.Now().UTC()
|
||||
result := Result{}
|
||||
|
||||
rules, err := c.getEligibleRules(now)
|
||||
if err != nil {
|
||||
return result, errors.Wrap(err, "getting eligible repetition rules")
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"hour": now.Hour(),
|
||||
"minute": now.Minute(),
|
||||
"num_rules": len(rules),
|
||||
}).Info("processing rules")
|
||||
|
||||
for _, rule := range rules {
|
||||
err := c.process(now, rule)
|
||||
|
||||
if err == nil {
|
||||
result.SuccessCount = result.SuccessCount + 1
|
||||
} else {
|
||||
log.WithFields(log.Fields{
|
||||
"rule uuid": rule.UUID,
|
||||
}).ErrorWrap(err, "Could not process the repetition rule")
|
||||
|
||||
result.FailedRuleUUIDs = append(result.FailedRuleUUIDs, rule.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.FailedRuleUUIDs) > 0 {
|
||||
return result, errors.New("failed to process some rules")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -1,501 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package repetition
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func assertLastActive(t *testing.T, ruleUUID string, lastActive int64) {
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", ruleUUID).First(&rule), "finding rule1")
|
||||
|
||||
assert.Equal(t, rule.LastActive, lastActive, "LastActive mismatch")
|
||||
}
|
||||
|
||||
func assertDigestCount(t *testing.T, rule database.RepetitionRule, expected int) {
|
||||
var digestCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Digest{}).Where("rule_id = ? AND user_id = ?", rule.ID, rule.UserID).Count(&digestCount), "counting digest")
|
||||
assert.Equal(t, digestCount, expected, "digest count mismatch")
|
||||
}
|
||||
|
||||
func getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) Context {
|
||||
emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR")
|
||||
|
||||
return Context{
|
||||
DB: testutils.DB,
|
||||
Clock: c,
|
||||
EmailTmpl: mailer.NewTemplates(&emailTmplDir),
|
||||
EmailBackend: be,
|
||||
}
|
||||
}
|
||||
|
||||
func mustDo(t *testing.T, c Context) {
|
||||
_, err := Do(c)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "performing"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo(t *testing.T) {
|
||||
t.Run("processes the rule on time", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Set up
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 0, 0, 0, 0, time.UTC)
|
||||
t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
Frequency: (time.Hour * 24 * 3).Milliseconds(), // three days
|
||||
Hour: 12,
|
||||
Minute: 2,
|
||||
Enabled: true,
|
||||
LastActive: 0,
|
||||
NextActive: t1.UnixNano() / int64(time.Millisecond),
|
||||
UserID: user.ID,
|
||||
BookDomain: database.BookDomainAll,
|
||||
Model: database.Model{
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
},
|
||||
}
|
||||
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
c := clock.NewMock()
|
||||
be := testutils.MockEmailbackendImplementation{}
|
||||
con := getTestContext(c, &be)
|
||||
|
||||
// Test
|
||||
// 1 day later
|
||||
c.SetNow(time.Date(2009, time.November, 2, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(0))
|
||||
assertDigestCount(t, r1, 0)
|
||||
assert.Equalf(t, len(be.Emails), 0, "email queue count mismatch")
|
||||
|
||||
// 2 days later
|
||||
c.SetNow(time.Date(2009, time.November, 3, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(0))
|
||||
assertDigestCount(t, r1, 0)
|
||||
assert.Equal(t, len(be.Emails), 0, "email queue count mismatch")
|
||||
|
||||
// 3 days later - should be processed
|
||||
c.SetNow(time.Date(2009, time.November, 4, 12, 1, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(0))
|
||||
assertDigestCount(t, r1, 0)
|
||||
assert.Equal(t, len(be.Emails), 0, "email queue count mismatch")
|
||||
|
||||
c.SetNow(time.Date(2009, time.November, 4, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257336120000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
c.SetNow(time.Date(2009, time.November, 4, 12, 3, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257336120000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
// 4 day later
|
||||
c.SetNow(time.Date(2009, time.November, 5, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257336120000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
// 5 days later
|
||||
c.SetNow(time.Date(2009, time.November, 6, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257336120000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
// 6 days later - should be processed
|
||||
c.SetNow(time.Date(2009, time.November, 7, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257595320000))
|
||||
assertDigestCount(t, r1, 2)
|
||||
assert.Equal(t, len(be.Emails), 2, "email queue count mismatch")
|
||||
|
||||
// 7 days later
|
||||
c.SetNow(time.Date(2009, time.November, 8, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257595320000))
|
||||
assertDigestCount(t, r1, 2)
|
||||
assert.Equal(t, len(be.Emails), 2, "email queue count mismatch")
|
||||
|
||||
// 8 days later
|
||||
c.SetNow(time.Date(2009, time.November, 9, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257595320000))
|
||||
assertDigestCount(t, r1, 2)
|
||||
assert.Equal(t, len(be.Emails), 2, "email queue count mismatch")
|
||||
|
||||
// 9 days later - should be processed
|
||||
c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC))
|
||||
mustDo(t, con)
|
||||
assertLastActive(t, r1.UUID, int64(1257854520000))
|
||||
assertDigestCount(t, r1, 3)
|
||||
assert.Equal(t, len(be.Emails), 3, "email queue count mismatch")
|
||||
})
|
||||
|
||||
/*
|
||||
* |----|----|----|----|----|----|----|----|----|----|----|----|----|
|
||||
* t0 t1 td t2 tu t3 t4
|
||||
*
|
||||
* Suppose a repetition with a frequency of 3 days.
|
||||
*
|
||||
* t0 - original last_active value (Nov 1, 2009)
|
||||
* t1 - original next_active value (Nov 4, 2009)
|
||||
* td - server goes down
|
||||
* t2 - repetition processing is missed (Nov 7, 2009)
|
||||
* tu - server comes up
|
||||
* t3 - new last_active value (Nov 10, 2009)
|
||||
* t4 - new next_active value (Nov 13, 2009)
|
||||
*/
|
||||
t.Run("recovers correct next_active value if missed processing in the past", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Set up
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 12, 2, 0, 0, time.UTC)
|
||||
t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
Frequency: (time.Hour * 24 * 3).Milliseconds(), // three days
|
||||
Hour: 12,
|
||||
Minute: 2,
|
||||
Enabled: true,
|
||||
LastActive: t0.UnixNano() / int64(time.Millisecond),
|
||||
NextActive: t1.UnixNano() / int64(time.Millisecond),
|
||||
UserID: user.ID,
|
||||
BookDomain: database.BookDomainAll,
|
||||
Model: database.Model{
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
},
|
||||
}
|
||||
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
var rule database.RepetitionRule
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", r1.UUID).First(&rule), "finding rule1")
|
||||
|
||||
assert.Equal(t, rule.LastActive, time.Date(2009, time.November, 10, 12, 2, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond), "LastActive mismsatch")
|
||||
assert.Equal(t, rule.NextActive, time.Date(2009, time.November, 13, 12, 2, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond), "NextActive mismsatch")
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDo_Disabled(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Set up
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 0, 0, 0, 0, time.UTC)
|
||||
t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
Frequency: (time.Hour * 24 * 3).Milliseconds(), // three days
|
||||
Hour: 12,
|
||||
Minute: 2,
|
||||
LastActive: 0,
|
||||
NextActive: t1.UnixNano() / int64(time.Millisecond),
|
||||
UserID: user.ID,
|
||||
Enabled: false,
|
||||
BookDomain: database.BookDomainAll,
|
||||
Model: database.Model{
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
},
|
||||
}
|
||||
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
// Test
|
||||
assertLastActive(t, r1.UUID, int64(0))
|
||||
assertDigestCount(t, r1, 0)
|
||||
assert.Equal(t, len(be.Emails), 0, "email queue count mismatch")
|
||||
}
|
||||
|
||||
func TestDo_BalancedStrategy(t *testing.T) {
|
||||
type testData struct {
|
||||
User database.User
|
||||
Book1 database.Book
|
||||
Book2 database.Book
|
||||
Book3 database.Book
|
||||
Note1 database.Note
|
||||
Note2 database.Note
|
||||
Note3 database.Note
|
||||
}
|
||||
|
||||
setup := func() testData {
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
b2 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
|
||||
b3 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "golang",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
|
||||
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
n2 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b2.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
|
||||
n3 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b3.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
|
||||
|
||||
return testData{
|
||||
User: user,
|
||||
Book1: b1,
|
||||
Book2: b2,
|
||||
Book3: b3,
|
||||
Note1: n1,
|
||||
Note2: n2,
|
||||
Note3: n3,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("all books", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Set up
|
||||
dat := setup()
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC)
|
||||
t1 := time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC)
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
Frequency: (time.Hour * 24 * 7).Milliseconds(),
|
||||
Hour: 21,
|
||||
Minute: 0,
|
||||
LastActive: 0,
|
||||
NextActive: t1.UnixNano() / int64(time.Millisecond),
|
||||
Enabled: true,
|
||||
UserID: dat.User.ID,
|
||||
BookDomain: database.BookDomainAll,
|
||||
NoteCount: 5,
|
||||
Model: database.Model{
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
},
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
// Test
|
||||
assertLastActive(t, r1.UUID, int64(1257714000000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
var repetition database.Digest
|
||||
testutils.MustExec(t, testutils.DB.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition")
|
||||
|
||||
sort.SliceStable(repetition.Notes, func(i, j int) bool {
|
||||
n1 := repetition.Notes[i]
|
||||
n2 := repetition.Notes[j]
|
||||
|
||||
return n1.ID < n2.ID
|
||||
})
|
||||
|
||||
var n1Record, n2Record, n3Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", dat.Note1.UUID).First(&n1Record), "finding n1")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", dat.Note3.UUID).First(&n3Record), "finding n3")
|
||||
expected := []database.Note{n1Record, n2Record, n3Record}
|
||||
assert.DeepEqual(t, repetition.Notes, expected, "result mismatch")
|
||||
})
|
||||
|
||||
t.Run("excluding books", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Set up
|
||||
dat := setup()
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC)
|
||||
t1 := time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC)
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
Frequency: (time.Hour * 24 * 7).Milliseconds(),
|
||||
Hour: 21,
|
||||
Enabled: true,
|
||||
Minute: 0,
|
||||
LastActive: 0,
|
||||
NextActive: t1.UnixNano() / int64(time.Millisecond),
|
||||
UserID: dat.User.ID,
|
||||
BookDomain: database.BookDomainExluding,
|
||||
Books: []database.Book{dat.Book1},
|
||||
NoteCount: 5,
|
||||
Model: database.Model{
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
},
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 1, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
// Test
|
||||
assertLastActive(t, r1.UUID, int64(1257714000000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
var repetition database.Digest
|
||||
testutils.MustExec(t, testutils.DB.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition")
|
||||
|
||||
sort.SliceStable(repetition.Notes, func(i, j int) bool {
|
||||
n1 := repetition.Notes[i]
|
||||
n2 := repetition.Notes[j]
|
||||
|
||||
return n1.ID < n2.ID
|
||||
})
|
||||
|
||||
var n2Record, n3Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", dat.Note3.UUID).First(&n3Record), "finding n3")
|
||||
expected := []database.Note{n2Record, n3Record}
|
||||
assert.DeepEqual(t, repetition.Notes, expected, "result mismatch")
|
||||
})
|
||||
|
||||
t.Run("including books", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
// Set up
|
||||
dat := setup()
|
||||
|
||||
t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC)
|
||||
t1 := time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC)
|
||||
r1 := database.RepetitionRule{
|
||||
Title: "Rule 1",
|
||||
Frequency: (time.Hour * 24 * 7).Milliseconds(),
|
||||
Hour: 21,
|
||||
Enabled: true,
|
||||
Minute: 0,
|
||||
LastActive: 0,
|
||||
NextActive: t1.UnixNano() / int64(time.Millisecond),
|
||||
UserID: dat.User.ID,
|
||||
BookDomain: database.BookDomainIncluding,
|
||||
Books: []database.Book{dat.Book1, dat.Book2},
|
||||
NoteCount: 5,
|
||||
Model: database.Model{
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
},
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
|
||||
|
||||
// Execute
|
||||
c := clock.NewMock()
|
||||
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC))
|
||||
be := &testutils.MockEmailbackendImplementation{}
|
||||
|
||||
mustDo(t, getTestContext(c, be))
|
||||
|
||||
// Test
|
||||
assertLastActive(t, r1.UUID, int64(1257714000000))
|
||||
assertDigestCount(t, r1, 1)
|
||||
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
|
||||
|
||||
var repetition database.Digest
|
||||
testutils.MustExec(t, testutils.DB.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition")
|
||||
|
||||
sort.SliceStable(repetition.Notes, func(i, j int) bool {
|
||||
n1 := repetition.Notes[i]
|
||||
n2 := repetition.Notes[j]
|
||||
|
||||
return n1.ID < n2.ID
|
||||
})
|
||||
|
||||
var n1Record, n2Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", dat.Note1.UUID).First(&n1Record), "finding n1")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2")
|
||||
expected := []database.Note{n1Record, n2Record}
|
||||
assert.DeepEqual(t, repetition.Notes, expected, "result mismatch")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package repetition
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func getRuleBookIDs(db *gorm.DB, ruleID int) ([]int, error) {
|
||||
var ret []int
|
||||
if err := db.Table("repetition_rule_books").Select("book_id").Where("repetition_rule_id = ?", ruleID).Pluck("book_id", &ret).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "querying book_ids")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func applyBookDomain(db *gorm.DB, noteQuery *gorm.DB, rule database.RepetitionRule) (*gorm.DB, error) {
|
||||
ret := noteQuery
|
||||
|
||||
if rule.BookDomain != database.BookDomainAll {
|
||||
bookIDs, err := getRuleBookIDs(db, rule.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getting book_ids")
|
||||
}
|
||||
|
||||
ret = ret.Joins("INNER JOIN books ON notes.book_uuid = books.uuid")
|
||||
|
||||
if rule.BookDomain == database.BookDomainExluding {
|
||||
ret = ret.Where("books.id NOT IN (?)", bookIDs)
|
||||
} else if rule.BookDomain == database.BookDomainIncluding {
|
||||
ret = ret.Where("books.id IN (?)", bookIDs)
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func getNotes(db, conn *gorm.DB, rule database.RepetitionRule, dst *[]database.Note) error {
|
||||
c, err := applyBookDomain(db, conn, rule)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "building query for book threahold 1")
|
||||
}
|
||||
|
||||
// TODO: ordering by random() does not scale if table grows large
|
||||
if err := c.Where("notes.user_id = ?", rule.UserID).Order("random()").Limit(rule.NoteCount).Preload("Book").Find(&dst).Error; err != nil {
|
||||
return errors.Wrap(err, "getting notes")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBalancedNotes returns a set of notes with a 'balanced' ratio of added_on dates
|
||||
func getBalancedNotes(db *gorm.DB, rule database.RepetitionRule) ([]database.Note, error) {
|
||||
now := time.Now()
|
||||
t1 := now.AddDate(0, 0, -3).UnixNano()
|
||||
t2 := now.AddDate(0, 0, -7).UnixNano()
|
||||
|
||||
baseConn := db.Where("notes.deleted IS NOT true")
|
||||
|
||||
// Get notes into three buckets with different threshold values
|
||||
var stage1, stage2, stage3 []database.Note
|
||||
if err := getNotes(db, baseConn.Where("notes.added_on > ?", t1), rule, &stage1); err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to get notes with threshold 1")
|
||||
}
|
||||
if err := getNotes(db, baseConn.Where("notes.added_on > ? AND notes.added_on < ?", t2, t1), rule, &stage2); err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to get notes with threshold 2")
|
||||
}
|
||||
if err := getNotes(db, baseConn.Where("notes.added_on < ?", t2), rule, &stage3); err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to get notes with threshold 3")
|
||||
}
|
||||
|
||||
notes := []database.Note{}
|
||||
|
||||
// pick one from each bucket at a time until the result is filled
|
||||
i1 := 0
|
||||
i2 := 0
|
||||
i3 := 0
|
||||
k := 0
|
||||
for {
|
||||
if i1+i2+i3 >= rule.NoteCount {
|
||||
break
|
||||
}
|
||||
|
||||
// if there are not enough notes to fill the result, break
|
||||
if len(stage1) == i1 && len(stage2) == i2 && len(stage3) == i3 {
|
||||
break
|
||||
}
|
||||
|
||||
if k%3 == 0 {
|
||||
if len(stage1) > i1 {
|
||||
i1++
|
||||
}
|
||||
} else if k%3 == 1 {
|
||||
if len(stage2) > i2 {
|
||||
i2++
|
||||
}
|
||||
} else if k%3 == 2 {
|
||||
if len(stage3) > i3 {
|
||||
i3++
|
||||
}
|
||||
}
|
||||
|
||||
k++
|
||||
}
|
||||
|
||||
notes = append(notes, stage1[:i1]...)
|
||||
notes = append(notes, stage2[:i2]...)
|
||||
notes = append(notes, stage3[:i3]...)
|
||||
|
||||
sort.SliceStable(notes, func(i, j int) bool {
|
||||
n1 := notes[i]
|
||||
n2 := notes[j]
|
||||
|
||||
return n1.AddedOn > n2.AddedOn
|
||||
})
|
||||
|
||||
return notes, nil
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package repetition
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
testutils.InitTestDB()
|
||||
}
|
||||
|
||||
func TestApplyBookDomain(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
b2 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
|
||||
b3 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "golang",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
|
||||
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
n2 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b2.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
|
||||
n3 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b3.UUID,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
|
||||
|
||||
var n1Record, n2Record, n3Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n3.UUID).First(&n3Record), "finding n3")
|
||||
|
||||
t.Run("book domain all", func(t *testing.T) {
|
||||
rule := database.RepetitionRule{
|
||||
UserID: user.ID,
|
||||
BookDomain: database.BookDomainAll,
|
||||
}
|
||||
|
||||
conn, err := applyBookDomain(testutils.DB, testutils.DB, rule)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "executing").Error())
|
||||
}
|
||||
|
||||
var result []database.Note
|
||||
testutils.MustExec(t, conn.Order("id ASC").Find(&result), "finding notes")
|
||||
|
||||
expected := []database.Note{n1Record, n2Record, n3Record}
|
||||
assert.DeepEqual(t, result, expected, "result mismatch")
|
||||
})
|
||||
|
||||
t.Run("book domain exclude", func(t *testing.T) {
|
||||
rule := database.RepetitionRule{
|
||||
UserID: user.ID,
|
||||
BookDomain: database.BookDomainExluding,
|
||||
Books: []database.Book{b1},
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&rule), "preparing rule")
|
||||
|
||||
conn, err := applyBookDomain(testutils.DB, testutils.DB, rule)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "executing").Error())
|
||||
}
|
||||
|
||||
var result []database.Note
|
||||
testutils.MustExec(t, conn.Order("id ASC").Find(&result), "finding notes")
|
||||
|
||||
expected := []database.Note{n2Record, n3Record}
|
||||
assert.DeepEqual(t, result, expected, "result mismatch")
|
||||
})
|
||||
}
|
||||
|
|
@ -42,8 +42,6 @@ var (
|
|||
EmailTypeEmailVerification = "verify_email"
|
||||
// EmailTypeWelcome represents an welcome email
|
||||
EmailTypeWelcome = "welcome"
|
||||
// EmailTypeInactiveReminder represents an inactivity reminder email
|
||||
EmailTypeInactiveReminder = "inactive"
|
||||
// EmailTypeSubscriptionConfirmation represents an inactivity reminder email
|
||||
EmailTypeSubscriptionConfirmation = "subscription_confirmation"
|
||||
)
|
||||
|
|
@ -109,10 +107,6 @@ func NewTemplates(srcDir *string) Templates {
|
|||
if err != nil {
|
||||
panic(errors.Wrap(err, "initializing password reset template"))
|
||||
}
|
||||
inactiveReminderText, err := initTextTmpl(box, EmailTypeInactiveReminder)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "initializing password reset template"))
|
||||
}
|
||||
subscriptionConfirmationText, err := initTextTmpl(box, EmailTypeSubscriptionConfirmation)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "initializing password reset template"))
|
||||
|
|
@ -127,7 +121,6 @@ func NewTemplates(srcDir *string) Templates {
|
|||
T.set(EmailTypeResetPasswordAlert, EmailKindText, passwordResetAlertText)
|
||||
T.set(EmailTypeEmailVerification, EmailKindText, verifyEmailText)
|
||||
T.set(EmailTypeWelcome, EmailKindText, welcomeText)
|
||||
T.set(EmailTypeInactiveReminder, EmailKindText, inactiveReminderText)
|
||||
T.set(EmailTypeSubscriptionConfirmation, EmailKindText, subscriptionConfirmationText)
|
||||
T.set(EmailTypeDigest, EmailKindText, digestText)
|
||||
|
||||
|
|
|
|||
|
|
@ -134,21 +134,6 @@ func (c Context) welcomeHandler(w http.ResponseWriter, r *http.Request) {
|
|||
w.Write([]byte(body))
|
||||
}
|
||||
|
||||
func (c Context) inactiveHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := mailer.InactiveReminderTmplData{
|
||||
SampleNoteUUID: "some-uuid",
|
||||
WebURL: "http://localhost:3000",
|
||||
Token: "some-random-token",
|
||||
}
|
||||
body, err := c.Tmpl.Execute(mailer.EmailTypeInactiveReminder, mailer.EmailKindText, data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(body))
|
||||
}
|
||||
|
||||
func (c Context) homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("Email development server is running."))
|
||||
}
|
||||
|
|
@ -187,6 +172,5 @@ func main() {
|
|||
http.HandleFunc("/password-reset", ctx.passwordResetHandler)
|
||||
http.HandleFunc("/password-reset-alert", ctx.passwordResetAlertHandler)
|
||||
http.HandleFunc("/welcome", ctx.welcomeHandler)
|
||||
http.HandleFunc("/inactive-reminder", ctx.inactiveHandler)
|
||||
log.Fatal(http.ListenAndServe(":2300", nil))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,13 +82,6 @@ type WelcomeTmplData struct {
|
|||
WebURL string
|
||||
}
|
||||
|
||||
// InactiveReminderTmplData is a template data for welcome emails
|
||||
type InactiveReminderTmplData struct {
|
||||
SampleNoteUUID string
|
||||
WebURL string
|
||||
Token string
|
||||
}
|
||||
|
||||
// EmailTypeSubscriptionConfirmationTmplData is a template data for reset password emails
|
||||
type EmailTypeSubscriptionConfirmationTmplData struct {
|
||||
AccountEmail string
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import (
|
|||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/dbconn"
|
||||
"github.com/dnote/dnote/pkg/server/handlers"
|
||||
"github.com/dnote/dnote/pkg/server/job"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/web"
|
||||
"github.com/jinzhu/gorm"
|
||||
|
|
@ -121,18 +120,6 @@ func initApp() app.App {
|
|||
}
|
||||
}
|
||||
|
||||
func runJob(a app.App) error {
|
||||
jobRunner, err := job.NewRunner(a.DB, a.Clock, a.EmailTemplates, a.EmailBackend, a.Config)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting a job runner")
|
||||
}
|
||||
if err := jobRunner.Do(); err != nil {
|
||||
return errors.Wrap(err, "running job")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func startCmd() {
|
||||
app := initApp()
|
||||
defer app.DB.Close()
|
||||
|
|
@ -141,10 +128,6 @@ func startCmd() {
|
|||
panic(errors.Wrap(err, "running migrations"))
|
||||
}
|
||||
|
||||
if err := runJob(app); err != nil {
|
||||
panic(errors.Wrap(err, "running job"))
|
||||
}
|
||||
|
||||
srv, err := initServer(app)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "initializing server"))
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package operations
|
||||
|
||||
import (
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// CreateDigest creates a new digest
|
||||
func CreateDigest(db *gorm.DB, rule database.RepetitionRule, notes []database.Note) (database.Digest, error) {
|
||||
var maxVersion int
|
||||
if err := db.Raw("SELECT COALESCE(max(version), 0) FROM digests WHERE rule_id = ?", rule.ID).Row().Scan(&maxVersion); err != nil {
|
||||
return database.Digest{}, errors.Wrap(err, "finding max version")
|
||||
}
|
||||
|
||||
digest := database.Digest{
|
||||
RuleID: rule.ID,
|
||||
UserID: rule.UserID,
|
||||
Version: maxVersion + 1,
|
||||
Notes: notes,
|
||||
}
|
||||
if err := db.Save(&digest).Error; err != nil {
|
||||
return database.Digest{}, errors.Wrap(err, "saving digest")
|
||||
}
|
||||
|
||||
return digest, nil
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package operations
|
||||
|
||||
import (
|
||||
// "fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestCreateDigest(t *testing.T) {
|
||||
t.Run("no previous digest", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
db := testutils.DB
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
rule := database.RepetitionRule{UserID: user.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&rule), "preparing rule")
|
||||
|
||||
result, err := CreateDigest(db, rule, nil)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "performing"))
|
||||
}
|
||||
|
||||
assert.Equal(t, result.Version, 1, "Version mismatch")
|
||||
})
|
||||
|
||||
t.Run("with previous digest", func(t *testing.T) {
|
||||
defer testutils.ClearData()
|
||||
|
||||
db := testutils.DB
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
rule := database.RepetitionRule{UserID: user.ID}
|
||||
testutils.MustExec(t, testutils.DB.Save(&rule), "preparing rule")
|
||||
|
||||
d := database.Digest{UserID: user.ID, RuleID: rule.ID, Version: 8}
|
||||
testutils.MustExec(t, testutils.DB.Save(&d), "preparing digest")
|
||||
|
||||
result, err := CreateDigest(db, rule, nil)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "performing"))
|
||||
}
|
||||
|
||||
assert.Equal(t, result.Version, 9, "Version mismatch")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package presenters
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
)
|
||||
|
||||
// Digest is a presented digest
|
||||
type Digest struct {
|
||||
UUID string `json:"uuid"`
|
||||
Version int `json:"version"`
|
||||
RepetitionRule RepetitionRule `json:"repetition_rule"`
|
||||
Notes []DigestNote `json:"notes"`
|
||||
IsRead bool `json:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DigestNote is a presented note inside a digest
|
||||
type DigestNote struct {
|
||||
Note
|
||||
IsReviewed bool `json:"is_reviewed"`
|
||||
}
|
||||
|
||||
func presentDigestNote(note database.Note) DigestNote {
|
||||
ret := DigestNote{
|
||||
Note: PresentNote(note),
|
||||
IsReviewed: note.NoteReview.UUID != "",
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func presentDigestNotes(notes []database.Note) []DigestNote {
|
||||
ret := []DigestNote{}
|
||||
|
||||
for _, note := range notes {
|
||||
n := presentDigestNote(note)
|
||||
ret = append(ret, n)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// PresentDigest presents a digest
|
||||
func PresentDigest(digest database.Digest) Digest {
|
||||
ret := Digest{
|
||||
UUID: digest.UUID,
|
||||
Notes: presentDigestNotes(digest.Notes),
|
||||
Version: digest.Version,
|
||||
RepetitionRule: PresentRepetitionRule(digest.Rule),
|
||||
IsRead: len(digest.Receipts) > 0,
|
||||
CreatedAt: digest.CreatedAt,
|
||||
UpdatedAt: digest.UpdatedAt,
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// PresentDigests presetns digests
|
||||
func PresentDigests(digests []database.Digest) []Digest {
|
||||
ret := []Digest{}
|
||||
|
||||
for _, digest := range digests {
|
||||
p := PresentDigest(digest)
|
||||
ret = append(ret, p)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package presenters
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
)
|
||||
|
||||
// DigestReceipt is a presented receipt
|
||||
type DigestReceipt struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PresentDigestReceipt presents a receipt
|
||||
func PresentDigestReceipt(receipt database.DigestReceipt) DigestReceipt {
|
||||
ret := DigestReceipt{
|
||||
CreatedAt: receipt.CreatedAt,
|
||||
UpdatedAt: receipt.UpdatedAt,
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// PresentDigestReceipts presents receipts
|
||||
func PresentDigestReceipts(receipts []database.DigestReceipt) []DigestReceipt {
|
||||
ret := []DigestReceipt{}
|
||||
|
||||
for _, receipt := range receipts {
|
||||
r := PresentDigestReceipt(receipt)
|
||||
ret = append(ret, r)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
@ -26,19 +26,17 @@ import (
|
|||
|
||||
// EmailPreference is a presented email digest
|
||||
type EmailPreference struct {
|
||||
InactiveReminder bool `json:"inactive_reminder"`
|
||||
ProductUpdate bool `json:"product_update"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ProductUpdate bool `json:"product_update"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PresentEmailPreference presents a digest
|
||||
func PresentEmailPreference(p database.EmailPreference) EmailPreference {
|
||||
ret := EmailPreference{
|
||||
InactiveReminder: p.InactiveReminder,
|
||||
ProductUpdate: p.ProductUpdate,
|
||||
CreatedAt: FormatTS(p.CreatedAt),
|
||||
UpdatedAt: FormatTS(p.UpdatedAt),
|
||||
ProductUpdate: p.ProductUpdate,
|
||||
CreatedAt: FormatTS(p.CreatedAt),
|
||||
UpdatedAt: FormatTS(p.UpdatedAt),
|
||||
}
|
||||
|
||||
return ret
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package presenters
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
)
|
||||
|
||||
// RepetitionRule is a presented digest rule
|
||||
type RepetitionRule struct {
|
||||
UUID string `json:"uuid"`
|
||||
Title string `json:"title"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Hour int `json:"hour" gorm:"index"`
|
||||
Minute int `json:"minute" gorm:"index"`
|
||||
Frequency int64 `json:"frequency"`
|
||||
BookDomain string `json:"book_domain"`
|
||||
LastActive int64 `json:"last_active"`
|
||||
NextActive int64 `json:"next_active"`
|
||||
Books []Book `json:"books"`
|
||||
NoteCount int `json:"note_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PresentRepetitionRule presents a digest rule
|
||||
func PresentRepetitionRule(d database.RepetitionRule) RepetitionRule {
|
||||
ret := RepetitionRule{
|
||||
UUID: d.UUID,
|
||||
Title: d.Title,
|
||||
Enabled: d.Enabled,
|
||||
Hour: d.Hour,
|
||||
Minute: d.Minute,
|
||||
Frequency: d.Frequency,
|
||||
BookDomain: d.BookDomain,
|
||||
NoteCount: d.NoteCount,
|
||||
LastActive: d.LastActive,
|
||||
NextActive: d.NextActive,
|
||||
Books: PresentBooks(d.Books),
|
||||
CreatedAt: FormatTS(d.CreatedAt),
|
||||
UpdatedAt: FormatTS(d.UpdatedAt),
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// PresentRepetitionRules presents a slice of digest rules
|
||||
func PresentRepetitionRules(ds []database.RepetitionRule) []RepetitionRule {
|
||||
ret := []RepetitionRule{}
|
||||
|
||||
for _, d := range ds {
|
||||
p := PresentRepetitionRule(d)
|
||||
ret = append(ret, p)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package presenters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
)
|
||||
|
||||
func TestPresentRepetitionRule(t *testing.T) {
|
||||
b1 := database.Book{UUID: "1cf8794f-4d61-4a9d-a9da-18f8db9e53cc", Label: "foo"}
|
||||
b2 := database.Book{UUID: "ede00f3b-eab1-469c-ae12-c60cebeeef17", Label: "bar"}
|
||||
d1 := database.RepetitionRule{
|
||||
UUID: "c725afb5-8bf1-4581-a0e7-0f683c15f3d0",
|
||||
Title: "test title",
|
||||
Enabled: true,
|
||||
Hour: 1,
|
||||
Minute: 2,
|
||||
LastActive: 1571293000,
|
||||
NextActive: 1571394000,
|
||||
NoteCount: 10,
|
||||
BookDomain: database.BookDomainAll,
|
||||
Books: []database.Book{b1, b2},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
input database.RepetitionRule
|
||||
expected RepetitionRule
|
||||
}{
|
||||
{
|
||||
input: d1,
|
||||
expected: RepetitionRule{
|
||||
UUID: d1.UUID,
|
||||
Title: d1.Title,
|
||||
Enabled: d1.Enabled,
|
||||
Hour: d1.Hour,
|
||||
Minute: d1.Minute,
|
||||
BookDomain: d1.BookDomain,
|
||||
NoteCount: d1.NoteCount,
|
||||
LastActive: d1.LastActive,
|
||||
NextActive: d1.NextActive,
|
||||
Books: []Book{
|
||||
{
|
||||
UUID: b1.UUID,
|
||||
USN: b1.USN,
|
||||
CreatedAt: b1.CreatedAt,
|
||||
UpdatedAt: b1.UpdatedAt,
|
||||
Label: b1.Label,
|
||||
},
|
||||
{
|
||||
UUID: b2.UUID,
|
||||
USN: b2.USN,
|
||||
CreatedAt: b2.CreatedAt,
|
||||
UpdatedAt: b2.UpdatedAt,
|
||||
Label: b2.Label,
|
||||
},
|
||||
},
|
||||
CreatedAt: d1.CreatedAt,
|
||||
UpdatedAt: d1.UpdatedAt,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
|
||||
result := PresentRepetitionRule(tc.input)
|
||||
|
||||
assert.DeepEqual(t, result, tc.expected, "result mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -88,21 +88,6 @@ func ClearData() {
|
|||
if err := DB.Delete(&database.Session{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear sessions"))
|
||||
}
|
||||
if err := DB.Delete(&database.Digest{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear digests"))
|
||||
}
|
||||
if err := DB.Delete(&database.DigestNote{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear digests"))
|
||||
}
|
||||
if err := DB.Delete(&database.DigestReceipt{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear digest receipts"))
|
||||
}
|
||||
if err := DB.Delete(&database.RepetitionRule{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear repetition rules"))
|
||||
}
|
||||
if err := DB.Delete(&database.NoteReview{}).Error; err != nil {
|
||||
panic(errors.Wrap(err, "Failed to clear note review"))
|
||||
}
|
||||
}
|
||||
|
||||
// SetupUserData creates and returns a new user for testing purposes
|
||||
|
|
@ -182,10 +167,9 @@ func SetupSession(t *testing.T, user database.User) database.Session {
|
|||
}
|
||||
|
||||
// SetupEmailPreferenceData creates and returns a new email frequency for a user
|
||||
func SetupEmailPreferenceData(user database.User, inactiveReminder bool) database.EmailPreference {
|
||||
func SetupEmailPreferenceData(user database.User) database.EmailPreference {
|
||||
frequency := database.EmailPreference{
|
||||
UserID: user.ID,
|
||||
InactiveReminder: inactiveReminder,
|
||||
UserID: user.ID,
|
||||
}
|
||||
|
||||
if err := DB.Save(&frequency).Error; err != nil {
|
||||
|
|
|
|||
|
|
@ -19,12 +19,7 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import {
|
||||
getNewPath,
|
||||
getBooksPath,
|
||||
getRepetitionsPath,
|
||||
getDigestsPath
|
||||
} from 'web/libs/paths';
|
||||
import { getNewPath, getBooksPath } from 'web/libs/paths';
|
||||
import { Filters, toSearchObj } from 'jslib/helpers/filters';
|
||||
import Item from './Item';
|
||||
import styles from './Nav.scss';
|
||||
|
|
@ -41,8 +36,6 @@ const Nav: React.FunctionComponent<Props> = ({ filters }) => {
|
|||
<ul className={classnames('list-unstyled', styles.list)}>
|
||||
<Item to={getNewPath(searchObj)} label="New" />
|
||||
<Item to={getBooksPath(searchObj)} label="Books" />
|
||||
<Item to={getRepetitionsPath(searchObj)} label="Repetition" />
|
||||
<Item to={getDigestsPath(searchObj)} label="Digests" />
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,17 +26,11 @@ import { useDispatch } from '../../../store';
|
|||
import styles from './Form.scss';
|
||||
|
||||
enum Action {
|
||||
setInactiveReminder,
|
||||
setProductUpdate
|
||||
}
|
||||
|
||||
function formReducer(state, action): EmailPrefData {
|
||||
switch (action.type) {
|
||||
case Action.setInactiveReminder:
|
||||
return {
|
||||
...state,
|
||||
inactiveReminder: action.data
|
||||
};
|
||||
case Action.setProductUpdate:
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -92,31 +86,6 @@ const Form: React.FunctionComponent<Props> = ({
|
|||
|
||||
return (
|
||||
<form id="T-notifications-form" onSubmit={handleSubmit}>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.heading}>Alerts</h3>
|
||||
<p className={styles.subtext}>Email me when:</p>
|
||||
<ul className="list-unstyled">
|
||||
<li>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inactive-reminder"
|
||||
checked={formState.inactiveReminder}
|
||||
onChange={e => {
|
||||
const { checked } = e.target;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setInactiveReminder,
|
||||
data: checked
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label className={styles.label} htmlFor="inactive-reminder">
|
||||
I stop learning new things
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.heading}>News</h3>
|
||||
|
||||
|
|
|
|||
|
|
@ -37,14 +37,9 @@ import Books from './components/Books';
|
|||
import Subscription from './components/Subscription';
|
||||
import Classic from './components/Classic';
|
||||
import Checkout from './components/Subscription/Checkout';
|
||||
import Repetition from './components/Repetition';
|
||||
import NewRepetition from './components/Repetition/New';
|
||||
import EditRepetition from './components/Repetition/Edit';
|
||||
import PasswordResetRequest from './components/PasswordReset/Request';
|
||||
import PasswordResetConfirm from './components/PasswordReset/Confirm';
|
||||
import EmailPreference from './components/EmailPreference';
|
||||
import Digests from './components/Digests';
|
||||
import Digest from './components/Digest';
|
||||
|
||||
// paths
|
||||
import {
|
||||
|
|
@ -64,12 +59,7 @@ import {
|
|||
prefEditRepetitionPathDef,
|
||||
verifyEmailPathDef,
|
||||
classicMigrationPathDef,
|
||||
repetitionsPathDef,
|
||||
newRepetitionRulePathDef,
|
||||
editRepetitionRulePathDef,
|
||||
emailPreferencePathDef,
|
||||
digestsPathDef,
|
||||
digestPathDef
|
||||
emailPreferencePathDef
|
||||
} from './libs/paths';
|
||||
|
||||
const AuthenticatedHome = userOnly(Home);
|
||||
|
|
@ -89,9 +79,6 @@ const AuthenticatedSubscriptionCheckout = userOnly(
|
|||
Checkout,
|
||||
getJoinPath().pathname
|
||||
);
|
||||
const AuthenticatedRepetition = userOnly(Repetition);
|
||||
const AuthenticatedNewRepetition = userOnly(NewRepetition);
|
||||
const AuthenticatedEditRepetition = userOnly(EditRepetition);
|
||||
|
||||
const routes = [
|
||||
{
|
||||
|
|
@ -169,36 +156,11 @@ const routes = [
|
|||
exact: true,
|
||||
component: GuestPasswordResetConfirm
|
||||
},
|
||||
{
|
||||
path: repetitionsPathDef,
|
||||
exact: true,
|
||||
component: AuthenticatedRepetition
|
||||
},
|
||||
{
|
||||
path: newRepetitionRulePathDef,
|
||||
exact: true,
|
||||
component: AuthenticatedNewRepetition
|
||||
},
|
||||
{
|
||||
path: editRepetitionRulePathDef,
|
||||
exact: true,
|
||||
component: AuthenticatedEditRepetition
|
||||
},
|
||||
{
|
||||
path: emailPreferencePathDef,
|
||||
exact: true,
|
||||
component: EmailPreference
|
||||
},
|
||||
{
|
||||
path: digestsPathDef,
|
||||
exact: true,
|
||||
component: Digests
|
||||
},
|
||||
{
|
||||
path: digestPathDef,
|
||||
exact: true,
|
||||
component: Digest
|
||||
},
|
||||
{
|
||||
component: NotFound
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue