Delete unused

This commit is contained in:
Sung Won Cho 2020-01-28 11:00:33 +11:00
commit e2e991e3d4
41 changed files with 263 additions and 4648 deletions

View file

@ -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).

View file

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

View file

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

View file

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

View file

@ -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")

View file

@ -45,11 +45,6 @@ func InitSchema(db *gorm.DB) {
Token{},
EmailPreference{},
Session{},
Digest{},
DigestNote{},
RepetitionRule{},
DigestReceipt{},
NoteReview{},
).Error; err != nil {
panic(err)
}

View file

@ -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"`
}

View file

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

View file

@ -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, "")
})
}

View file

@ -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(&params)
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(&params)
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
}
}

View file

@ -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(&noteReviewCount), "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(&noteReviewRecord), "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(&noteReviewCount), "counting note_reviews")
assert.Equal(t, noteReviewCount, 0, "counting note_review")
}

View file

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

View file

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

View file

@ -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},

View file

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

View file

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

View file

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

View file

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

View file

@ -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(&notif)
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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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"))

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

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

View file

@ -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 {

View file

@ -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>
);

View file

@ -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>

View file

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