diff --git a/.travis.yml b/.travis.yml index c208106d..d2cd8161 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: go dist: xenial go: - - 1.12 + - 1.13 env: - NODE_VERSION=10.15.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfed335c..7b953116 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ This repository contains the server side and the client side code for Dnote. 1. Install the following prerequisites if necessary: -* [Go programming language](https://golang.org/dl/) 1.12+ +* [Go programming language](https://golang.org/dl/) 1.13+ * [Node.js](https://nodejs.org/) 10.16+ * Postgres 10.9+ diff --git a/Gopkg.lock b/Gopkg.lock index d352c06b..c61f773b 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -90,6 +90,20 @@ revision = "9eb7a3d310e89e471c2cdf1ea3ec8d7fc1ab969c" version = "v2.5.2" +[[projects]] + digest = "1:1d1cbf539d9ac35eb3148129f96be5537f1a1330cadcc7e3a83b4e72a59672a3" + name = "github.com/google/go-cmp" + packages = [ + "cmp", + "cmp/internal/diff", + "cmp/internal/flags", + "cmp/internal/function", + "cmp/internal/value", + ] + pruneopts = "UT" + revision = "2d0692c2e9617365a95b295612ac0d4415ba4627" + version = "v0.3.1" + [[projects]] digest = "1:bfb6d8aee23cd9b2db8fa3760ca11d7a934d9a05993d5233406f1e6042e4c110" name = "github.com/google/go-github" @@ -106,6 +120,14 @@ revision = "44c6ddd0a2342c386950e880b658017258da92fc" version = "v1.0.0" +[[projects]] + digest = "1:582b704bebaa06b48c29b0cec224a6058a09c86883aaddabde889cd1a5f73e1b" + name = "github.com/google/uuid" + packages = ["."] + pruneopts = "UT" + revision = "0cd6bf5da1e1c83f8b45653022c74f71af0538a4" + version = "v1.1.1" + [[projects]] digest = "1:fc51ecee8f31d03436c1a0167eb1e383ad0a241d02272541853f3995374a08f1" name = "github.com/gorilla/css" @@ -154,6 +176,14 @@ revision = "23d116af351c84513e1946b527c88823e476be13" version = "v1.3.0" +[[projects]] + branch = "master" + digest = "1:77fa3714d5009a706a77970c39b690c951473ab05560baab801f14055218c9ed" + name = "github.com/justincampbell/timeago" + packages = ["."] + pruneopts = "UT" + revision = "027f40306f1dbe89d24087611680ef95543bf876" + [[projects]] digest = "1:77857b3205f936bdc6928ef347b682ab549cf99454d6c0ca04a49f8df9e418f3" name = "github.com/karrick/godirwalk" @@ -244,14 +274,6 @@ pruneopts = "UT" revision = "f4d34eae5a5cf210693e81c604e6bac5f6727927" -[[projects]] - digest = "1:274f67cb6fed9588ea2521ecdac05a6d62a8c51c074c1fccc6a49a40ba80e925" - name = "github.com/satori/go.uuid" - packages = ["."] - pruneopts = "UT" - revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3" - version = "v1.2.0" - [[projects]] digest = "1:d917313f309bda80d27274d53985bc65651f81a5b66b820749ac7f8ef061fd04" name = "github.com/sergi/go-diff" @@ -392,16 +414,18 @@ "github.com/dnote/actions", "github.com/dnote/color", "github.com/gobuffalo/packr/v2", + "github.com/google/go-cmp/cmp", "github.com/google/go-github/github", + "github.com/google/uuid", "github.com/gorilla/mux", "github.com/jinzhu/gorm", "github.com/joho/godotenv", + "github.com/justincampbell/timeago", "github.com/lib/pq", "github.com/mattn/go-sqlite3", "github.com/pkg/errors", "github.com/robfig/cron", "github.com/rubenv/sql-migrate", - "github.com/satori/go.uuid", "github.com/sergi/go-diff/diffmatchpatch", "github.com/spf13/cobra", "github.com/stripe/stripe-go", diff --git a/Gopkg.toml b/Gopkg.toml index a44f73cc..0bf77d19 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -57,10 +57,6 @@ name = "github.com/lib/pq" version = "1.1.1" -[[constraint]] - name = "github.com/markbates/goth" - version = "1.54.1" - [[constraint]] name = "github.com/mattn/go-sqlite3" version = "1.10.0" @@ -73,10 +69,6 @@ name = "github.com/robfig/cron" version = "1.2.0" -[[constraint]] - name = "github.com/satori/go.uuid" - version = "1.2.0" - [[constraint]] name = "github.com/sergi/go-diff" version = "1.0.0" diff --git a/Makefile b/Makefile index b7566daf..1043caff 100644 --- a/Makefile +++ b/Makefile @@ -61,7 +61,7 @@ test-cli: test-api: @echo "==> running API test" - @${GOPATH}/src/github.com/dnote/dnote/pkg/server/api/scripts/test-local.sh + @${GOPATH}/src/github.com/dnote/dnote/pkg/server/scripts/test-local.sh .PHONY: test-api test-web: diff --git a/jslib/src/helpers/books.ts b/jslib/src/helpers/books.ts index dd8e3de0..3f6c52b6 100644 --- a/jslib/src/helpers/books.ts +++ b/jslib/src/helpers/books.ts @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -import { BookData } from '../operations/books'; +import { BookData } from '../operations/types'; // errBookNameNumeric is an error for book names that only contain numbers export const errBookNameNumeric = new Error( diff --git a/jslib/src/helpers/http.ts b/jslib/src/helpers/http.ts index f4d22540..c2912c2c 100644 --- a/jslib/src/helpers/http.ts +++ b/jslib/src/helpers/http.ts @@ -115,8 +115,11 @@ export function getHttpClient(c: HttpClientConfig) { get: (path: string, options = {}) => { return get(transformPath(path), options); }, - post: (path: string, data = {}, options = {}) => { - return post(transformPath(path), data, options); + post: (path: string, data = {}, options = {}) => { + return post(transformPath(path), data, options).then(resp => { + console.log('check', resp); + return resp; + }); }, patch: (path: string, data, options = {}) => { return patch(transformPath(path), data, options); diff --git a/jslib/src/helpers/keyboard.ts b/jslib/src/helpers/keyboard.ts index b8e7d37f..997ec358 100644 --- a/jslib/src/helpers/keyboard.ts +++ b/jslib/src/helpers/keyboard.ts @@ -20,7 +20,9 @@ export const KEYCODE_DOWN = 40; export const KEYCODE_UP = 38; export const KEYCODE_ENTER = 13; export const KEYCODE_ESC = 27; +export const KEYCODE_SPACE = 32; export const KEYCODE_TAB = 9; +export const KEYCODE_BACKSPACE = 8; // alphabet export const KEYCODE_LOWERCASE_B = 66; diff --git a/jslib/src/helpers/select.ts b/jslib/src/helpers/select.ts index bbaf5e77..93578e6a 100644 --- a/jslib/src/helpers/select.ts +++ b/jslib/src/helpers/select.ts @@ -16,7 +16,7 @@ * along with Dnote. If not, see . */ -import { BookData } from '../operations/books'; +import { BookData } from '../operations/types'; // Option represents an option in a selection list export interface Option { diff --git a/jslib/src/operations/books.ts b/jslib/src/operations/books.ts index 1d6218d3..391736eb 100644 --- a/jslib/src/operations/books.ts +++ b/jslib/src/operations/books.ts @@ -18,14 +18,7 @@ import initBooksService from '../services/books'; import { HttpClientConfig } from '../helpers/http'; - -export type BookData = { - uuid: string; - usn: number; - created_at: string; - updated_at: string; - label: string; -}; +import { BookData } from './types'; export interface CreateParams { name: string; diff --git a/jslib/src/operations/types.ts b/jslib/src/operations/types.ts index cb600a3c..93f1579d 100644 --- a/jslib/src/operations/types.ts +++ b/jslib/src/operations/types.ts @@ -47,3 +47,38 @@ export interface UserData { pro: boolean; classic: boolean; } + +export type BookData = { + uuid: string; + usn: number; + created_at: string; + updated_at: string; + label: string; +}; + +// BookDomain is the possible values for the field in the repetition_rule +// indicating how to derive the source books for the repetition_rule. +export enum BookDomain { + // All incidates that all books are eligible to be the source books + All = 'all', + // Including incidates that some specified books are eligible to be the source books + Including = 'including', + // Excluding incidates that all books except for some specified books are eligible to be the source books + Excluding = 'excluding' +} + +export interface RepetitionRuleData { + uuid: string; + title: string; + enabled: boolean; + hour: number; + minute: number; + bookDomain: BookDomain; + frequency: number; + books: BookData[]; + lastActive: number; + nextActive: number; + noteCount: number; + createdAt: string; + updatedAt: string; +} diff --git a/jslib/src/services/digests.ts b/jslib/src/services/digests.ts index 4e04bdd7..bc9249d1 100644 --- a/jslib/src/services/digests.ts +++ b/jslib/src/services/digests.ts @@ -23,24 +23,14 @@ export default function init(config: HttpClientConfig) { const client = getHttpClient(config); return { - fetch: (digestUUID, { demo }) => { - let endpoint; - if (demo) { - endpoint = `/demo/digests/${digestUUID}`; - } else { - endpoint = `/digests/${digestUUID}`; - } + fetch: digestUUID => { + const endpoint = `/digests/${digestUUID}`; return client.get(endpoint); }, - fetchAll: ({ page, demo }) => { - let path; - if (demo) { - path = `/demo/digests`; - } else { - path = '/digests'; - } + fetchAll: ({ page }) => { + const path = '/digests'; const endpoint = getPath(path, { page }); diff --git a/jslib/src/services/index.ts b/jslib/src/services/index.ts index b103da0e..f342a95a 100644 --- a/jslib/src/services/index.ts +++ b/jslib/src/services/index.ts @@ -20,8 +20,9 @@ import { HttpClientConfig } from '../helpers/http'; import initUsersService from './users'; import initBooksService from './books'; import initNotesService from './notes'; -import initDigestsService from './digests'; import initPaymentService from './payment'; +import initDigestsService from './digests'; +import initRepetitionRulesService from './repetitionRules'; // init initializes service helpers with the given http configuration // and returns an object of all services. @@ -29,14 +30,16 @@ export default function initServices(c: HttpClientConfig) { const usersService = initUsersService(c); const booksService = initBooksService(c); const notesService = initNotesService(c); - const digestsService = initDigestsService(c); const paymentService = initPaymentService(c); + const digestsService = initDigestsService(c); + const repetitionRulesService = initRepetitionRulesService(c); return { users: usersService, books: booksService, notes: notesService, + payment: paymentService, digests: digestsService, - payment: paymentService + repetitionRules: repetitionRulesService }; } diff --git a/jslib/src/services/repetitionRules.ts b/jslib/src/services/repetitionRules.ts new file mode 100644 index 00000000..79b89a3a --- /dev/null +++ b/jslib/src/services/repetitionRules.ts @@ -0,0 +1,116 @@ +/* Copyright (C) 2019 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 . + */ + +import { BookData, RepetitionRuleData, BookDomain } from '../operations/types'; +import { getHttpClient, HttpClientConfig } from '../helpers/http'; +import { getPath } from '../helpers/url'; + +export type FetchResponse = RepetitionRuleData[]; + +export interface CreateParams { + title: string; + hour: number; + minute: number; + book_domain: BookDomain; + frequency: number; + note_count: number; + book_uuids: string[]; + enabled: boolean; +} + +export type UpdateParams = Partial; + +export interface RepetitionRuleRespData { + uuid: string; + title: string; + enabled: boolean; + hour: number; + minute: number; + book_domain: BookDomain; + frequency: number; + books: BookData[]; + note_count: number; + last_active: number; + next_active: number; + created_at: string; + updated_at: string; +} + +function mapData(d: RepetitionRuleRespData): RepetitionRuleData { + return { + uuid: d.uuid, + title: d.title, + enabled: d.enabled, + hour: d.hour, + minute: d.minute, + bookDomain: d.book_domain, + frequency: d.frequency, + books: d.books, + noteCount: d.note_count, + lastActive: d.last_active, + nextActive: d.next_active, + createdAt: d.created_at, + updatedAt: d.updated_at + }; +} + +export default function init(config: HttpClientConfig) { + const client = getHttpClient(config); + + return { + fetch: (uuid: string, queries = {}): Promise => { + const path = `/repetition_rules/${uuid}`; + const endpoint = getPath(path, queries); + + return client.get(endpoint).then(resp => { + return mapData(resp); + }); + }, + fetchAll: (): Promise => { + const endpoint = '/repetition_rules'; + + return client.get(endpoint).then(resp => { + return resp.map(mapData); + }); + }, + create: (params: CreateParams) => { + const endpoint = '/repetition_rules'; + + return client + .post(endpoint, params) + .then(resp => { + return mapData(resp); + }); + }, + update: (uuid: string, params: UpdateParams, queries = {}) => { + const path = `/repetition_rules/${uuid}`; + const endpoint = getPath(path, queries); + + return client + .patch(endpoint, params) + .then(resp => { + return mapData(resp); + }); + }, + remove: (uuid: string) => { + const endpoint = `/repetition_rules/${uuid}`; + + return client.del(endpoint); + } + }; +} diff --git a/pkg/assert/assert.go b/pkg/assert/assert.go index e565cd47..c2433bcc 100644 --- a/pkg/assert/assert.go +++ b/pkg/assert/assert.go @@ -25,8 +25,10 @@ import ( "io/ioutil" "net/http" "reflect" + "runtime/debug" "testing" + "github.com/google/go-cmp/cmp" "github.com/pkg/errors" ) @@ -40,7 +42,9 @@ Actual: Expected: ======================== %+v -========================`, m, a, b) +======================== + +%s`, m, a, b, string(debug.Stack())) } func checkEqual(a, b interface{}, message string) (bool, string) { @@ -94,6 +98,7 @@ func DeepEqual(t *testing.T, a, b interface{}, message string) { } errorMessage := getErrorMessage(message, a, b) + errorMessage = fmt.Sprintf("%v\n%v", errorMessage, cmp.Diff(a, b)) t.Error(errorMessage) } diff --git a/pkg/cli/cmd/add/add.go b/pkg/cli/cmd/add/add.go index 4e1c57b5..3502778e 100644 --- a/pkg/cli/cmd/add/add.go +++ b/pkg/cli/cmd/add/add.go @@ -135,7 +135,10 @@ func writeNote(ctx context.DnoteCtx, bookLabel string, content string, ts int64) var bookUUID string err = tx.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&bookUUID) if err == sql.ErrNoRows { - bookUUID = utils.GenerateUUID() + bookUUID, err = utils.GenerateUUID() + if err != nil { + return 0, errors.Wrap(err, "generating uuid") + } b := database.NewBook(bookUUID, bookLabel, 0, false, true) err = b.Insert(tx) @@ -147,7 +150,11 @@ func writeNote(ctx context.DnoteCtx, bookLabel string, content string, ts int64) return 0, errors.Wrap(err, "finding the book") } - noteUUID := utils.GenerateUUID() + noteUUID, err := utils.GenerateUUID() + if err != nil { + return 0, errors.Wrap(err, "generating uuid") + } + n := database.NewNote(noteUUID, bookUUID, content, ts, 0, 0, false, false, true) err = n.Insert(tx) diff --git a/pkg/cli/cmd/remove/remove.go b/pkg/cli/cmd/remove/remove.go index 51a4f67c..75af85e5 100644 --- a/pkg/cli/cmd/remove/remove.go +++ b/pkg/cli/cmd/remove/remove.go @@ -192,7 +192,11 @@ func runBook(ctx context.DnoteCtx, bookLabel string) error { } // override the label with a random string - uniqLabel := utils.GenerateUUID() + uniqLabel, err := utils.GenerateUUID() + if err != nil { + return errors.Wrap(err, "generating uuid to override with") + } + if _, err = tx.Exec("UPDATE books SET deleted = ?, dirty = ?, label = ? WHERE uuid = ?", true, true, uniqLabel, bookUUID); err != nil { tx.Rollback() return errors.Wrap(err, "removing the book") diff --git a/pkg/cli/cmd/sync/merge.go b/pkg/cli/cmd/sync/merge.go index e9300cab..5ae27325 100644 --- a/pkg/cli/cmd/sync/merge.go +++ b/pkg/cli/cmd/sync/merge.go @@ -137,7 +137,11 @@ func getConflictsBookUUID(tx *database.DB) (string, error) { err := tx.QueryRow("SELECT uuid FROM books WHERE label = ?", "conflicts").Scan(&ret) if err == sql.ErrNoRows { // Create a conflicts book - ret = utils.GenerateUUID() + ret, err = utils.GenerateUUID() + if err != nil { + return "", err + } + b := database.NewBook(ret, "conflicts", 0, false, true) err = b.Insert(tx) if err != nil { diff --git a/pkg/cli/cmd/sync/sync_test.go b/pkg/cli/cmd/sync/sync_test.go index f12ceb63..90891c90 100644 --- a/pkg/cli/cmd/sync/sync_test.go +++ b/pkg/cli/cmd/sync/sync_test.go @@ -33,7 +33,6 @@ import ( "github.com/dnote/dnote/pkg/cli/context" "github.com/dnote/dnote/pkg/cli/database" "github.com/dnote/dnote/pkg/cli/testutils" - "github.com/dnote/dnote/pkg/cli/utils" "github.com/pkg/errors" ) @@ -235,7 +234,7 @@ func TestSyncDeleteNote(t *testing.T) { }) t.Run("local copy is dirty", func(t *testing.T) { - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) // set up db := database.InitTestDB(t, dbPath, nil) @@ -305,7 +304,7 @@ func TestSyncDeleteNote(t *testing.T) { }) t.Run("local copy is not dirty", func(t *testing.T) { - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) // set up db := database.InitTestDB(t, dbPath, nil) @@ -406,7 +405,7 @@ func TestSyncDeleteBook(t *testing.T) { }) t.Run("local copy is dirty", func(t *testing.T) { - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) // set up db := database.InitTestDB(t, dbPath, nil) @@ -471,8 +470,8 @@ func TestSyncDeleteBook(t *testing.T) { }) t.Run("local copy is not dirty", func(t *testing.T) { - b1UUID := utils.GenerateUUID() - b2UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) + b2UUID := testutils.MustGenerateUUID(t) // set up db := database.InitTestDB(t, dbPath, nil) @@ -538,7 +537,7 @@ func TestSyncDeleteBook(t *testing.T) { }) t.Run("local copy has at least one note that is dirty", func(t *testing.T) { - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) // set up db := database.InitTestDB(t, dbPath, nil) @@ -596,7 +595,7 @@ func TestFullSyncNote(t *testing.T) { db := database.InitTestDB(t, dbPath, nil) defer database.CloseTestDB(t, db) - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") // execute @@ -646,9 +645,9 @@ func TestFullSyncNote(t *testing.T) { }) t.Run("exists on server and client", func(t *testing.T) { - b1UUID := utils.GenerateUUID() - b2UUID := utils.GenerateUUID() - conflictBookUUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) + b2UUID := testutils.MustGenerateUUID(t) + conflictBookUUID := testutils.MustGenerateUUID(t) testCases := []struct { addedOn int64 @@ -831,7 +830,7 @@ n1 body edited database.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") database.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "b2-label") database.MustExec(t, fmt.Sprintf("inserting conflitcs book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", conflictBookUUID, "conflicts") - n1UUID := utils.GenerateUUID() + n1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, tc.clientBookUUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientBody, tc.clientDeleted, tc.clientDirty) // execute @@ -890,7 +889,7 @@ func TestFullSyncBook(t *testing.T) { db := database.InitTestDB(t, dbPath, nil) defer database.CloseTestDB(t, db) - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, 555, "b1-label", true, false) // execute @@ -899,7 +898,7 @@ func TestFullSyncBook(t *testing.T) { t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) } - b2UUID := utils.GenerateUUID() + b2UUID := testutils.MustGenerateUUID(t) b := client.SyncFragBook{ UUID: b2UUID, USN: 1, @@ -1029,7 +1028,7 @@ func TestFullSyncBook(t *testing.T) { db := database.InitTestDB(t, dbPath, nil) defer database.CloseTestDB(t, db) - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, tc.clientUSN, tc.clientLabel, tc.clientDirty, tc.clientDeleted) // execute @@ -1082,7 +1081,7 @@ func TestStepSyncNote(t *testing.T) { db := database.InitTestDB(t, dbPath, nil) defer database.CloseTestDB(t, db) - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") // execute @@ -1132,9 +1131,9 @@ func TestStepSyncNote(t *testing.T) { }) t.Run("exists on server and client", func(t *testing.T) { - b1UUID := utils.GenerateUUID() - b2UUID := utils.GenerateUUID() - conflictBookUUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) + b2UUID := testutils.MustGenerateUUID(t) + conflictBookUUID := testutils.MustGenerateUUID(t) testCases := []struct { addedOn int64 @@ -1243,7 +1242,7 @@ n1 body edited database.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1-label") database.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "b2-label") database.MustExec(t, fmt.Sprintf("inserting conflitcs book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", conflictBookUUID, "conflicts") - n1UUID := utils.GenerateUUID() + n1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, tc.clientBookUUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientBody, tc.clientDeleted, tc.clientDirty) // execute @@ -1302,7 +1301,7 @@ func TestStepSyncBook(t *testing.T) { db := database.InitTestDB(t, dbPath, nil) defer database.CloseTestDB(t, db) - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, 555, "b1-label", true, false) // execute @@ -1311,7 +1310,7 @@ func TestStepSyncBook(t *testing.T) { t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) } - b2UUID := utils.GenerateUUID() + b2UUID := testutils.MustGenerateUUID(t) b := client.SyncFragBook{ UUID: b2UUID, USN: 1, @@ -1425,9 +1424,9 @@ func TestStepSyncBook(t *testing.T) { db := database.InitTestDB(t, dbPath, nil) defer database.CloseTestDB(t, db) - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, tc.clientUSN, tc.clientLabel, tc.clientDirty, tc.clientDeleted) - b2UUID := utils.GenerateUUID() + b2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, fmt.Sprintf("inserting book for test case %d", idx), db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b2UUID, 2, tc.anotherBookLabel, false, false) // execute @@ -1660,7 +1659,7 @@ func TestMergeBook(t *testing.T) { t.Fatalf(errors.Wrap(err, "beginning a transaction").Error()) } - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, usn, label, dirty, deleted) VALUES (?, ?, ?, ?, ?)", b1UUID, 1, "b1-label", false, false) b1 := client.SyncFragBook{ @@ -2401,7 +2400,7 @@ func TestSendNotes_addedOn(t *testing.T) { if r.URL.String() == "/v3/notes" && r.Method == "POST" { resp := client.CreateNoteResp{ Result: client.RespNote{ - UUID: utils.GenerateUUID(), + UUID: testutils.MustGenerateUUID(t), }, } @@ -2649,7 +2648,7 @@ func TestSendNotes_isBehind(t *testing.T) { func TestMergeNote(t *testing.T) { b1UUID := "b1-uuid" b2UUID := "b2-uuid" - conflictBookUUID := utils.GenerateUUID() + conflictBookUUID := testutils.MustGenerateUUID(t) testCases := []struct { addedOn int64 @@ -2786,7 +2785,7 @@ n1 body edited database.MustExec(t, fmt.Sprintf("inserting b1 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, dirty) VALUES (?, ?, ?, ?)", b1UUID, "b1-label", 5, false) database.MustExec(t, fmt.Sprintf("inserting b2 for test case %d", idx), db, "INSERT INTO books (uuid, label, usn, dirty) VALUES (?, ?, ?, ?)", b2UUID, "b2-label", 6, false) database.MustExec(t, fmt.Sprintf("inserting conflitcs book for test case %d", idx), db, "INSERT INTO books (uuid, label) VALUES (?, ?)", conflictBookUUID, "conflicts") - n1UUID := utils.GenerateUUID() + n1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, fmt.Sprintf("inserting n1 for test case %d", idx), db, "INSERT INTO notes (uuid, book_uuid, usn, added_on, edited_on, body, deleted, dirty) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n1UUID, b1UUID, tc.clientUSN, tc.addedOn, tc.clientEditedOn, tc.clientBody, tc.clientDeleted, tc.clientDirty) // execute diff --git a/pkg/cli/migrate/legacy.go b/pkg/cli/migrate/legacy.go index 00099a15..ad565aad 100644 --- a/pkg/cli/migrate/legacy.go +++ b/pkg/cli/migrate/legacy.go @@ -30,8 +30,8 @@ import ( "github.com/dnote/dnote/pkg/cli/context" "github.com/dnote/dnote/pkg/cli/log" "github.com/dnote/dnote/pkg/cli/utils" + "github.com/google/uuid" "github.com/pkg/errors" - "github.com/satori/go.uuid" "gopkg.in/yaml.v2" ) @@ -81,6 +81,16 @@ func makeSchema(complete bool) schema { return s } +func genUUID() (string, error) { + res, err := uuid.NewRandom() + if err != nil { + return "", errors.Wrap(err, "generating a random uuid") + } + + return res.String(), nil + +} + // Legacy performs migration on JSON-based dnote if necessary func Legacy(ctx context.DnoteCtx) error { // If schema does not exist, no need run a legacy migration @@ -510,8 +520,13 @@ func migrateToV2(ctx context.DnoteCtx) error { for bookName, book := range preDnote { var notes = make([]migrateToV2PostNote, 0, len(book)) for _, note := range book { + noteUUID, err := genUUID() + if err != nil { + return errors.Wrap(err, "generating uuid") + } + newNote := migrateToV2PostNote{ - UUID: uuid.NewV4().String(), + UUID: noteUUID, Content: note.Content, AddedOn: note.AddedOn, EditedOn: 0, @@ -703,8 +718,13 @@ func migrateToV5(ctx context.DnoteCtx) error { data = action.Data } + actionUUID, err := genUUID() + if err != nil { + return errors.Wrap(err, "generating UUID") + } + migrated := migrateToV5PostAction{ - UUID: uuid.NewV4().String(), + UUID: actionUUID, Schema: 1, Type: action.Type, Data: data, @@ -866,7 +886,13 @@ func migrateToV8(ctx context.DnoteCtx) error { } for bookName, book := range dnote { - bookUUID := uuid.NewV4().String() + bookUUIDResult, err := uuid.NewRandom() + if err != nil { + return errors.Wrap(err, "generating uuid") + } + + bookUUID := bookUUIDResult.String() + _, err = tx.Exec(`INSERT INTO books (uuid, label) VALUES (?, ?)`, bookUUID, bookName) if err != nil { tx.Rollback() diff --git a/pkg/cli/migrate/migrate_test.go b/pkg/cli/migrate/migrate_test.go index 73ab7e97..893656b1 100644 --- a/pkg/cli/migrate/migrate_test.go +++ b/pkg/cli/migrate/migrate_test.go @@ -34,7 +34,6 @@ import ( "github.com/dnote/dnote/pkg/cli/context" "github.com/dnote/dnote/pkg/cli/database" "github.com/dnote/dnote/pkg/cli/testutils" - "github.com/dnote/dnote/pkg/cli/utils" "github.com/pkg/errors" ) @@ -325,17 +324,17 @@ func TestLocalMigration1(t *testing.T) { db := ctx.DB data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"}) - a1UUID := utils.GenerateUUID() + a1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 1, "add_book", string(data), 1537829463) data = testutils.MustMarshalJSON(t, actions.EditNoteDataV1{NoteUUID: "note-1-uuid", FromBook: "js", ToBook: "", Content: "note 1"}) - a2UUID := utils.GenerateUUID() + a2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a2UUID, 1, "edit_note", string(data), 1537829463) data = testutils.MustMarshalJSON(t, actions.EditNoteDataV1{NoteUUID: "note-2-uuid", FromBook: "js", ToBook: "", Content: "note 2"}) - a3UUID := utils.GenerateUUID() + a3UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a3UUID, 1, "edit_note", string(data), 1537829463) @@ -405,21 +404,21 @@ func TestLocalMigration2(t *testing.T) { c2 := "note 1 - v2" css := "css" - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css") data := testutils.MustMarshalJSON(t, actions.AddNoteDataV2{NoteUUID: "note-1-uuid", BookName: "js", Content: "note 1", Public: false}) - a1UUID := utils.GenerateUUID() + a1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 2, "add_note", string(data), 1537829463) data = testutils.MustMarshalJSON(t, actions.EditNoteDataV2{NoteUUID: "note-1-uuid", FromBook: "js", ToBook: nil, Content: &c1, Public: nil}) - a2UUID := utils.GenerateUUID() + a2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a2UUID, 2, "edit_note", string(data), 1537829463) data = testutils.MustMarshalJSON(t, actions.EditNoteDataV2{NoteUUID: "note-1-uuid", FromBook: "js", ToBook: &css, Content: &c2, Public: nil}) - a3UUID := utils.GenerateUUID() + a3UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a3UUID, 2, "edit_note", string(data), 1537829463) @@ -488,17 +487,17 @@ func TestLocalMigration3(t *testing.T) { db := ctx.DB data := testutils.MustMarshalJSON(t, actions.AddNoteDataV2{NoteUUID: "note-1-uuid", BookName: "js", Content: "note 1", Public: false}) - a1UUID := utils.GenerateUUID() + a1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 2, "add_note", string(data), 1537829463) data = testutils.MustMarshalJSON(t, actions.RemoveNoteDataV1{NoteUUID: "note-1-uuid", BookName: "js"}) - a2UUID := utils.GenerateUUID() + a2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a2UUID, 1, "remove_note", string(data), 1537829463) data = testutils.MustMarshalJSON(t, actions.RemoveNoteDataV1{NoteUUID: "note-2-uuid", BookName: "js"}) - a3UUID := utils.GenerateUUID() + a3UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a3UUID, 1, "remove_note", string(data), 1537829463) @@ -562,9 +561,9 @@ func TestLocalMigration4(t *testing.T) { db := ctx.DB - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css") - n1UUID := utils.GenerateUUID() + n1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n1UUID, b1UUID, "n1 content", time.Now().UnixNano()) // Execute @@ -606,16 +605,16 @@ func TestLocalMigration5(t *testing.T) { db := ctx.DB - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting css book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "css") - b2UUID := utils.GenerateUUID() + b2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting js book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "js") - n1UUID := utils.GenerateUUID() + n1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n1UUID, b1UUID, "n1 content", time.Now().UnixNano()) - n2UUID := utils.GenerateUUID() + n2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n2UUID, b1UUID, "n2 content", time.Now().UnixNano()) - n3UUID := utils.GenerateUUID() + n3UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting css note", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", n3UUID, b1UUID, "n3 content", time.Now().UnixNano()) data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"}) @@ -669,7 +668,7 @@ func TestLocalMigration6(t *testing.T) { db := ctx.DB data := testutils.MustMarshalJSON(t, actions.AddBookDataV1{BookName: "js"}) - a1UUID := utils.GenerateUUID() + a1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting action", db, "INSERT INTO actions (uuid, schema, type, data, timestamp) VALUES (?, ?, ?, ?, ?)", a1UUID, 1, "add_book", string(data), 1537829463) @@ -701,7 +700,7 @@ func TestLocalMigration7_trash(t *testing.T) { db := ctx.DB - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting trash book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "trash") // Execute @@ -734,7 +733,7 @@ func TestLocalMigration7_conflicts(t *testing.T) { db := ctx.DB - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "conflicts") // Execute @@ -767,9 +766,9 @@ func TestLocalMigration7_conflicts_dup(t *testing.T) { db := ctx.DB - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "conflicts") - b2UUID := utils.GenerateUUID() + b2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "conflicts (2)") // Execute @@ -805,14 +804,14 @@ func TestLocalMigration8(t *testing.T) { db := ctx.DB - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1") - n1UUID := utils.GenerateUUID() + n1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting n1", db, `INSERT INTO notes (id, uuid, book_uuid, content, added_on, edited_on, public, dirty, usn, deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 1, n1UUID, b1UUID, "n1 Body", 1, 2, true, true, 20, false) - n2UUID := utils.GenerateUUID() + n2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting n2", db, `INSERT INTO notes (id, uuid, book_uuid, content, added_on, edited_on, public, dirty, usn, deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 2, n2UUID, b1UUID, "", 3, 4, false, true, 21, true) @@ -871,14 +870,14 @@ func TestLocalMigration9(t *testing.T) { db := ctx.DB - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "b1") - n1UUID := utils.GenerateUUID() + n1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting n1", db, `INSERT INTO notes (uuid, book_uuid, body, added_on, edited_on, public, dirty, usn, deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, n1UUID, b1UUID, "n1 Body", 1, 2, true, true, 20, false) - n2UUID := utils.GenerateUUID() + n2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting n2", db, `INSERT INTO notes (uuid, book_uuid, body, added_on, edited_on, public, dirty, usn, deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, n2UUID, b1UUID, "n2 Body", 3, 4, false, true, 21, false) @@ -917,21 +916,21 @@ func TestLocalMigration10(t *testing.T) { db := ctx.DB - b1UUID := utils.GenerateUUID() - database.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "123") - b2UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) + database.MustExec(t, "inserting book ", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "123") + b2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "123 javascript") - b3UUID := utils.GenerateUUID() + b3UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 3", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b3UUID, "foo") - b4UUID := utils.GenerateUUID() + b4UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 4", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b4UUID, "+123") - b5UUID := utils.GenerateUUID() + b5UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 5", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b5UUID, "0123") - b6UUID := utils.GenerateUUID() + b6UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 6", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b6UUID, "javascript 123") - b7UUID := utils.GenerateUUID() + b7UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 7", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b7UUID, "123 (1)") - b8UUID := utils.GenerateUUID() + b8UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 8", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b8UUID, "5") // Execute @@ -989,23 +988,23 @@ func TestLocalMigration11(t *testing.T) { db := ctx.DB - b1UUID := utils.GenerateUUID() + b1UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "foo") - b2UUID := utils.GenerateUUID() + b2UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "bar baz") - b3UUID := utils.GenerateUUID() + b3UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 3", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b3UUID, "quz qux") - b4UUID := utils.GenerateUUID() + b4UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 4", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b4UUID, "quz_qux") - b5UUID := utils.GenerateUUID() + b5UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 5", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b5UUID, "foo bar baz quz 123") - b6UUID := utils.GenerateUUID() + b6UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 6", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b6UUID, "foo_bar baz") - b7UUID := utils.GenerateUUID() + b7UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 7", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b7UUID, "cool ideas") - b8UUID := utils.GenerateUUID() + b8UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 8", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b8UUID, "cool_ideas") - b9UUID := utils.GenerateUUID() + b9UUID := testutils.MustGenerateUUID(t) database.MustExec(t, "inserting book 9", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b9UUID, "cool_ideas_2") // Execute diff --git a/pkg/cli/testutils/main.go b/pkg/cli/testutils/main.go index aa018359..76d10b9b 100644 --- a/pkg/cli/testutils/main.go +++ b/pkg/cli/testutils/main.go @@ -224,3 +224,13 @@ func MustUnmarshalJSON(t *testing.T, data []byte, v interface{}) { t.Fatalf("%s: unmarshalling data", t.Name()) } } + +// MustGenerateUUID generates the uuid. If error occurs, it fails the test. +func MustGenerateUUID(t *testing.T) string { + ret, err := utils.GenerateUUID() + if err != nil { + t.Fatal(errors.Wrap(err, "generating uuid").Error()) + } + + return ret +} diff --git a/pkg/cli/utils/utils.go b/pkg/cli/utils/utils.go index 72676bc8..b1db59ea 100644 --- a/pkg/cli/utils/utils.go +++ b/pkg/cli/utils/utils.go @@ -21,12 +21,18 @@ package utils import ( "regexp" - "github.com/satori/go.uuid" + "github.com/google/uuid" + "github.com/pkg/errors" ) -// GenerateUUID returns a uid -func GenerateUUID() string { - return uuid.NewV4().String() +// GenerateUUID returns a uuid v4 in string +func GenerateUUID() (string, error) { + u, err := uuid.NewRandom() + if err != nil { + return "", errors.Wrap(err, "generating uuid") + } + + return u.String(), nil } // regexNumber is a regex that matches a string that looks like an integer diff --git a/pkg/clock/clock.go b/pkg/clock/clock.go index a87488a3..d0e2a001 100644 --- a/pkg/clock/clock.go +++ b/pkg/clock/clock.go @@ -26,8 +26,7 @@ import ( //TODO: use mutex to avoid race // Clock is an interface to the standard library time. -// It is used to implement a real or a mock clock. The latter is -// used in tests. +// It is used to implement a real or a mock clock. The latter is used in tests. type Clock interface { Now() time.Time } diff --git a/pkg/server/api/handlers/auth.go b/pkg/server/api/handlers/auth.go index 6d579ca9..eea6f93b 100644 --- a/pkg/server/api/handlers/auth.go +++ b/pkg/server/api/handlers/auth.go @@ -84,7 +84,7 @@ func (a *App) getMe(w http.ResponseWriter, r *http.Request) { } tx.Commit() - respondJSON(w, response) + respondJSON(w, http.StatusOK, response) } type createResetTokenPayload struct { diff --git a/pkg/server/api/handlers/classic.go b/pkg/server/api/handlers/classic.go index 7816520e..ed85a766 100644 --- a/pkg/server/api/handlers/classic.go +++ b/pkg/server/api/handlers/classic.go @@ -215,7 +215,7 @@ func (a *App) classicGetMe(w http.ResponseWriter, r *http.Request) { User: session, } - respondJSON(w, response) + respondJSON(w, http.StatusOK, response) } type classicSetPasswordPayload struct { @@ -272,5 +272,5 @@ func (a *App) classicGetNotes(w http.ResponseWriter, r *http.Request) { } presented := presenters.PresentNotes(notes) - respondJSON(w, presented) + respondJSON(w, http.StatusOK, presented) } diff --git a/pkg/server/api/handlers/helpers.go b/pkg/server/api/handlers/helpers.go index 429b56a9..b097f9f6 100644 --- a/pkg/server/api/handlers/helpers.go +++ b/pkg/server/api/handlers/helpers.go @@ -126,8 +126,10 @@ func handleError(w http.ResponseWriter, msg string, err error, statusCode int) { } // respondJSON encodes the given payload into a JSON format and writes it to the given response writer -func respondJSON(w http.ResponseWriter, payload interface{}) { +func respondJSON(w http.ResponseWriter, statusCode int, payload interface{}) { w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(payload); err != nil { handleError(w, "encoding response", err, http.StatusInternalServerError) } diff --git a/pkg/server/api/handlers/notes.go b/pkg/server/api/handlers/notes.go index 1adc2edb..9f946829 100644 --- a/pkg/server/api/handlers/notes.go +++ b/pkg/server/api/handlers/notes.go @@ -76,7 +76,7 @@ ts_headline('english_nostop', notes.body, plainto_tsquery('english_nostop', ?), func respondWithNote(w http.ResponseWriter, note database.Note) { presentedNote := presenters.PresentNote(note) - respondJSON(w, presentedNote) + respondJSON(w, http.StatusOK, presentedNote) } func parseSearchQuery(q url.Values) string { @@ -182,7 +182,7 @@ func respondGetNotes(userID int, query url.Values, w http.ResponseWriter) { Notes: presenters.PresentNotes(notes), Total: total, } - respondJSON(w, response) + respondJSON(w, http.StatusOK, response) } type getNotesQuery struct { @@ -331,5 +331,5 @@ func (a *App) legacyGetNotes(w http.ResponseWriter, r *http.Request) { } presented := presenters.PresentNotes(notes) - respondJSON(w, presented) + respondJSON(w, http.StatusOK, presented) } diff --git a/pkg/server/api/handlers/repetition_rules.go b/pkg/server/api/handlers/repetition_rules.go new file mode 100644 index 00000000..c66d8b5b --- /dev/null +++ b/pkg/server/api/handlers/repetition_rules.go @@ -0,0 +1,460 @@ +/* Copyright (C) 2019 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 . + */ + +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/dnote/dnote/pkg/server/api/helpers" + "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/database" + "github.com/gorilla/mux" + "github.com/pkg/errors" +) + +func (a *App) 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 + } + + db := database.DBConn + var repetitionRule database.RepetitionRule + if err := 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 *App) 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 + } + + db := database.DBConn + var repetitionRules []database.RepetitionRule + if err := 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 *App) 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 + } + + db := database.DBConn + var books []database.Book + if err := 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.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 := 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 *App) 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"] + + db := database.DBConn + + var rule database.RepetitionRule + conn := 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 := 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 *App) 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 + } + + db := database.DBConn + tx := 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.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.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) +} diff --git a/pkg/server/api/handlers/repetition_rules_test.go b/pkg/server/api/handlers/repetition_rules_test.go new file mode 100644 index 00000000..3c3f00ab --- /dev/null +++ b/pkg/server/api/handlers/repetition_rules_test.go @@ -0,0 +1,649 @@ +/* Copyright (C) 2019 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 . + */ + +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/api/presenters" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/pkg/errors" +) + +func init() { + testutils.InitTestDB() +} + +func TestGetRepetitionRule(t *testing.T) { + defer testutils.ClearData() + db := database.DBConn + + // Setup + server := httptest.NewServer(NewRouter(&App{ + Clock: clock.NewMock(), + })) + defer server.Close() + + user := testutils.SetupUserData() + + b1 := database.Book{ + USN: 11, + Label: "js", + } + testutils.MustExec(t, 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, db.Save(&r1), "preparing rule1") + + // Execute + req := testutils.MakeReq(server, "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, db.Where("uuid = ?", r1.UUID).First(&r1Record), "finding r1Record") + var b1Record database.Book + testutils.MustExec(t, 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() + db := database.DBConn + + // Setup + server := httptest.NewServer(NewRouter(&App{ + Clock: clock.NewMock(), + })) + defer server.Close() + + user := testutils.SetupUserData() + + b1 := database.Book{ + USN: 11, + Label: "js", + } + testutils.MustExec(t, 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, 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, db.Save(&r2), "preparing rule2") + + // Execute + req := testutils.MakeReq(server, "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, db.Where("uuid = ?", r1.UUID).First(&r1Record), "finding r1Record") + testutils.MustExec(t, db.Where("uuid = ?", r2.UUID).First(&r2Record), "finding r2Record") + var b1Record database.Book + testutils.MustExec(t, 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() + db := database.DBConn + + // Setup + c := clock.NewMock() + t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC) + c.SetNow(t0) + server := httptest.NewServer(NewRouter(&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, "POST", "/repetition_rules", dat) + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusCreated, "") + + var ruleCount int + testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules") + assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch") + + var rule database.RepetitionRule + testutils.MustExec(t, 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() + db := database.DBConn + + // Setup + c := clock.NewMock() + t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC) + c.SetNow(t0) + server := httptest.NewServer(NewRouter(&App{ + Clock: c, + })) + defer server.Close() + + user := testutils.SetupUserData() + + b1 := database.Book{ + UserID: user.ID, + Label: "css", + } + testutils.MustExec(t, 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, "POST", "/repetition_rules", dat) + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusCreated, "") + + var ruleCount int + testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules") + assert.Equalf(t, ruleCount, 1, "reperition rule count mismatch") + + var rule database.RepetitionRule + testutils.MustExec(t, db.Preload("Books").First(&rule), "finding b1Record") + + var b1Record database.Book + testutils.MustExec(t, 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() + db := database.DBConn + + // Setup + c := clock.NewMock() + t0 := time.Date(2009, time.November, 1, 2, 3, 4, 5, time.UTC) + c.SetNow(t0) + server := httptest.NewServer(NewRouter(&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, db.Save(&r1), "preparing r1") + b1 := database.Book{ + UserID: user.ID, + USN: 11, + Label: "js", + } + testutils.MustExec(t, 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, "PATCH", endpoint, dat) + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var totalRuleCount int + testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&totalRuleCount), "counting rules") + assert.Equalf(t, totalRuleCount, 1, "reperition rule count mismatch") + + var rule database.RepetitionRule + testutils.MustExec(t, db.Preload("Books").First(&rule), "finding b1Record") + + var b1Record database.Book + testutils.MustExec(t, 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() + db := database.DBConn + + // Setup + server := httptest.NewServer(NewRouter(&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, 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, db.Save(&r2), "preparing r2") + + endpoint := fmt.Sprintf("/repetition_rules/%s", r1.UUID) + req := testutils.MakeReq(server, "DELETE", endpoint, "") + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusOK, "") + + var totalRuleCount int + testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&totalRuleCount), "counting rules") + assert.Equalf(t, totalRuleCount, 1, "reperition rule count mismatch") + + var r2Count int + testutils.MustExec(t, 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() + db := database.DBConn + + // Setup + server := httptest.NewServer(NewRouter(&App{ + Clock: clock.NewMock(), + })) + defer server.Close() + + user := testutils.SetupUserData() + + // Execute + req := testutils.MakeReq(server, "POST", "/repetition_rules", tc) + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusBadRequest, "") + + var ruleCount int + testutils.MustExec(t, 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() + db := database.DBConn + + // 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, db.Save(&r1), "preparing r1") + b1 := database.Book{ + UserID: user.ID, + USN: 11, + Label: "js", + } + testutils.MustExec(t, db.Save(&b1), "preparing book1") + + server := httptest.NewServer(NewRouter(&App{ + Clock: clock.NewMock(), + })) + defer server.Close() + + // Execute + req := testutils.MakeReq(server, "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, 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() + db := database.DBConn + + // Setup + server := httptest.NewServer(NewRouter(&App{ + Clock: clock.NewMock(), + })) + defer server.Close() + + user := testutils.SetupUserData() + + // Execute + req := testutils.MakeReq(server, "POST", "/repetition_rules", tc) + res := testutils.HTTPAuthDo(t, req, user) + + // Test + assert.StatusCodeEquals(t, res, http.StatusBadRequest, "") + + var ruleCount int + testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Count(&ruleCount), "counting rules") + assert.Equalf(t, ruleCount, 0, "reperition rule count mismatch") + }) + } +} diff --git a/pkg/server/api/handlers/routes.go b/pkg/server/api/handlers/routes.go index 0086668b..a49249b1 100644 --- a/pkg/server/api/handlers/routes.go +++ b/pkg/server/api/handlers/routes.go @@ -38,6 +38,9 @@ import ( // ErrInvalidAuthHeader is an error for invalid format of Authorization HTTP header var ErrInvalidAuthHeader = errors.New("Invalid authorization header") +// ErrForbidden is an error for forbidden requests +var ErrForbidden = errors.New("forbidden") + // Route represents a single route type Route struct { Method string @@ -66,6 +69,10 @@ func parseAuthHeader(h string) (authHeader, error) { return parsed, nil } +func respondForbidden(w http.ResponseWriter) { + http.Error(w, "forbidden", http.StatusForbidden) +} + func respondUnauthorized(w http.ResponseWriter) { unsetSessionCookie(w) w.Header().Add("WWW-Authenticate", `Bearer realm="Dnote Pro", charset="UTF-8"`) @@ -146,7 +153,7 @@ func getCredential(r *http.Request) (string, error) { return ret, nil } -func authWithSession(r *http.Request) (database.User, bool, error) { +func authWithSession(r *http.Request, p *authMiddlewareParams) (database.User, bool, error) { db := database.DBConn var user database.User @@ -180,10 +187,16 @@ func authWithSession(r *http.Request) (database.User, bool, error) { return user, false, errors.Wrap(err, "finding user from token") } + if p != nil && p.ProOnly { + if !user.Cloud { + return user, false, ErrForbidden + } + } + return user, true, nil } -func authWithToken(r *http.Request, tokenType string) (database.User, database.Token, bool, error) { +func authWithToken(r *http.Request, tokenType string, p *authMiddlewareParams) (database.User, database.Token, bool, error) { db := database.DBConn var user database.User var token database.Token @@ -209,6 +222,12 @@ func authWithToken(r *http.Request, tokenType string) (database.User, database.T return user, token, false, errors.Wrap(err, "finding user") } + if p != nil && p.ProOnly { + if !user.Cloud { + return user, token, false, ErrForbidden + } + } + return user, token, true, nil } @@ -218,17 +237,14 @@ type authMiddlewareParams struct { func auth(next http.HandlerFunc, p *authMiddlewareParams) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, ok, err := authWithSession(r) + user, ok, err := authWithSession(r, p) if !ok || err != nil { - respondUnauthorized(w) - return - } - - if p != nil && p.ProOnly { - if !user.Cloud { + if err == ErrForbidden { http.Error(w, "forbidden", http.StatusForbidden) - return + } else { + respondUnauthorized(w) } + return } ctx := context.WithValue(r.Context(), helpers.KeyUser, user) @@ -236,10 +252,15 @@ func auth(next http.HandlerFunc, p *authMiddlewareParams) http.HandlerFunc { }) } -func tokenAuth(next http.HandlerFunc, tokenType string) http.HandlerFunc { +func tokenAuth(next http.HandlerFunc, tokenType string, p *authMiddlewareParams) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, token, ok, err := authWithToken(r, tokenType) + user, token, ok, err := authWithToken(r, tokenType, p) if err != nil { + if err == ErrForbidden { + respondForbidden(w) + return + } + // log the error and continue log.ErrorWrap(err, "authenticating with token") } @@ -250,14 +271,18 @@ func tokenAuth(next http.HandlerFunc, tokenType string) http.HandlerFunc { ctx = context.WithValue(ctx, helpers.KeyToken, token) } else { // If token-based auth fails, fall back to session-based auth - user, ok, err = authWithSession(r) + user, ok, err = authWithSession(r, p) if err != nil { // log the error and continue log.ErrorWrap(err, "authenticating with session") } if !ok { - respondUnauthorized(w) + if err == ErrForbidden { + respondForbidden(w) + } else { + respondUnauthorized(w) + } return } } @@ -353,8 +378,8 @@ func NewRouter(app *App) *mux.Router { {"PATCH", "/reset-password", app.resetPassword, true}, {"PATCH", "/account/profile", auth(app.updateProfile, nil), true}, {"PATCH", "/account/password", auth(app.updatePassword, nil), true}, - {"GET", "/account/email-preference", tokenAuth(app.getEmailPreference, database.TokenTypeEmailPreference), true}, - {"PATCH", "/account/email-preference", tokenAuth(app.updateEmailPreference, database.TokenTypeEmailPreference), true}, + {"GET", "/account/email-preference", tokenAuth(app.getEmailPreference, database.TokenTypeEmailPreference, nil), true}, + {"PATCH", "/account/email-preference", tokenAuth(app.updateEmailPreference, database.TokenTypeEmailPreference, nil), true}, {"POST", "/subscriptions", auth(app.createSub, nil), true}, {"PATCH", "/subscriptions", auth(app.updateSub, nil), true}, {"POST", "/webhooks/stripe", app.stripeWebhook, true}, @@ -364,6 +389,11 @@ func NewRouter(app *App) *mux.Router { {"GET", "/notes", auth(app.getNotes, &proOnly), false}, {"GET", "/notes/{noteUUID}", auth(app.getNote, &proOnly), true}, {"GET", "/calendar", auth(app.getCalendar, &proOnly), true}, + {"GET", "/repetition_rules", auth(app.getRepetitionRules, &proOnly), true}, + {"GET", "/repetition_rules/{repetitionRuleUUID}", tokenAuth(app.getRepetitionRule, database.TokenTypeRepetition, &proOnly), true}, + {"POST", "/repetition_rules", auth(app.createRepetitionRule, &proOnly), true}, + {"PATCH", "/repetition_rules/{repetitionRuleUUID}", tokenAuth(app.updateRepetitionRule, database.TokenTypeRepetition, &proOnly), true}, + {"DELETE", "/repetition_rules/{repetitionRuleUUID}", auth(app.deleteRepetitionRule, &proOnly), true}, // migration of classic users {"GET", "/classic/presignin", cors(app.classicPresignin), true}, diff --git a/pkg/server/api/handlers/routes_test.go b/pkg/server/api/handlers/routes_test.go index 73712ef3..8d5bb90c 100644 --- a/pkg/server/api/handlers/routes_test.go +++ b/pkg/server/api/handlers/routes_test.go @@ -224,7 +224,7 @@ func TestAuthMiddleware(t *testing.T) { expectedStatus: http.StatusUnauthorized, }, { - header: fmt.Sprintf("Bearer neBchYaAYxJv4U22cx9Udxacp0HjvUIS4UEAqMIU1q0="), + header: fmt.Sprintf("Bearer someInvalidSessionKey="), expectedStatus: http.StatusUnauthorized, }, } @@ -267,7 +267,7 @@ func TestAuthMiddleware(t *testing.T) { { cookie: &http.Cookie{ Name: "id", - Value: "neBchYaAYxJv4U22cx9Udxacp0HjvUIS4UEAqMIU1q0=", + Value: "someInvalidSessionKey=", HttpOnly: true, }, expectedStatus: http.StatusUnauthorized, @@ -299,6 +299,96 @@ func TestAuthMiddleware(t *testing.T) { }) } +func TestAuthMiddleware_ProOnly(t *testing.T) { + defer testutils.ClearData() + + // set up + db := database.DBConn + + user := testutils.SetupUserData() + testutils.MustExec(t, db.Model(&user).Update("cloud", false), "preparing session") + session := database.Session{ + Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=", + UserID: user.ID, + ExpiresAt: time.Now().Add(time.Hour * 24), + } + testutils.MustExec(t, db.Save(&session), "preparing session") + + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + } + server := httptest.NewServer(auth(handler, &authMiddlewareParams{ + ProOnly: true, + })) + defer server.Close() + + t.Run("with header", func(t *testing.T) { + testCases := []struct { + header string + expectedStatus int + }{ + { + header: fmt.Sprintf("Bearer %s", session.Key), + expectedStatus: http.StatusForbidden, + }, + { + header: fmt.Sprintf("Bearer someInvalidSessionKey="), + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range testCases { + t.Run(tc.header, func(t *testing.T) { + req := testutils.MakeReq(server, "GET", "/", "") + req.Header.Set("Authorization", tc.header) + + // execute + res := testutils.HTTPDo(t, req) + + // test + assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch") + }) + } + }) + + t.Run("with cookie", func(t *testing.T) { + testCases := []struct { + cookie *http.Cookie + expectedStatus int + }{ + { + cookie: &http.Cookie{ + Name: "id", + Value: session.Key, + HttpOnly: true, + }, + expectedStatus: http.StatusForbidden, + }, + { + cookie: &http.Cookie{ + Name: "id", + Value: "someInvalidSessionKey=", + HttpOnly: true, + }, + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range testCases { + t.Run(tc.cookie.Value, func(t *testing.T) { + req := testutils.MakeReq(server, "GET", "/", "") + req.AddCookie(tc.cookie) + + // execute + res := testutils.HTTPDo(t, req) + + // test + assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch") + }) + } + }) +} + func TestTokenAuthMiddleWare(t *testing.T) { defer testutils.ClearData() @@ -322,7 +412,7 @@ func TestTokenAuthMiddleWare(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - server := httptest.NewServer(tokenAuth(handler, database.TokenTypeEmailPreference)) + server := httptest.NewServer(tokenAuth(handler, database.TokenTypeEmailPreference, nil)) defer server.Close() t.Run("with token", func(t *testing.T) { @@ -335,7 +425,7 @@ func TestTokenAuthMiddleWare(t *testing.T) { expectedStatus: http.StatusOK, }, { - token: "UlcKclI67wHfpbc1AX6skw==", + token: "someRandomToken==", expectedStatus: http.StatusUnauthorized, }, } @@ -363,7 +453,7 @@ func TestTokenAuthMiddleWare(t *testing.T) { expectedStatus: http.StatusOK, }, { - header: fmt.Sprintf("Bearer neBchYaAYxJv4U22cx9Udxacp0HjvUIS4UEAqMIU1q0="), + header: fmt.Sprintf("Bearer someInvalidSessionKey="), expectedStatus: http.StatusUnauthorized, }, } @@ -398,7 +488,141 @@ func TestTokenAuthMiddleWare(t *testing.T) { { cookie: &http.Cookie{ Name: "id", - Value: "neBchYaAYxJv4U22cx9Udxacp0HjvUIS4UEAqMIU1q0=", + Value: "someInvalidSessionKey=", + HttpOnly: true, + }, + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range testCases { + t.Run(tc.cookie.Value, func(t *testing.T) { + req := testutils.MakeReq(server, "GET", "/", "") + req.AddCookie(tc.cookie) + + // execute + res := testutils.HTTPDo(t, req) + + // test + assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch") + }) + } + }) + + t.Run("without anything", func(t *testing.T) { + req := testutils.MakeReq(server, "GET", "/", "") + + // execute + res := testutils.HTTPDo(t, req) + + // test + assert.Equal(t, res.StatusCode, http.StatusUnauthorized, "status code mismatch") + }) +} + +func TestTokenAuthMiddleWare_ProOnly(t *testing.T) { + defer testutils.ClearData() + + // set up + db := database.DBConn + + user := testutils.SetupUserData() + testutils.MustExec(t, db.Model(&user).Update("cloud", false), "preparing session") + tok := database.Token{ + UserID: user.ID, + Type: database.TokenTypeEmailPreference, + Value: "xpwFnc0MdllFUePDq9DLeQ==", + } + testutils.MustExec(t, db.Save(&tok), "preparing token") + session := database.Session{ + Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=", + UserID: user.ID, + ExpiresAt: time.Now().Add(time.Hour * 24), + } + testutils.MustExec(t, db.Save(&session), "preparing session") + + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + } + server := httptest.NewServer(tokenAuth(handler, database.TokenTypeEmailPreference, &authMiddlewareParams{ + ProOnly: true, + })) + defer server.Close() + + t.Run("with token", func(t *testing.T) { + testCases := []struct { + token string + expectedStatus int + }{ + { + token: "xpwFnc0MdllFUePDq9DLeQ==", + expectedStatus: http.StatusForbidden, + }, + { + token: "someRandomToken==", + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range testCases { + t.Run(tc.token, func(t *testing.T) { + req := testutils.MakeReq(server, "GET", fmt.Sprintf("/?token=%s", tc.token), "") + + // execute + res := testutils.HTTPDo(t, req) + + // test + assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch") + }) + } + }) + + t.Run("with session header", func(t *testing.T) { + testCases := []struct { + header string + expectedStatus int + }{ + { + header: fmt.Sprintf("Bearer %s", session.Key), + expectedStatus: http.StatusForbidden, + }, + { + header: fmt.Sprintf("Bearer someInvalidSessionKey="), + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range testCases { + t.Run(tc.header, func(t *testing.T) { + req := testutils.MakeReq(server, "GET", "/", "") + req.Header.Set("Authorization", tc.header) + + // execute + res := testutils.HTTPDo(t, req) + + // test + assert.Equal(t, res.StatusCode, tc.expectedStatus, "status code mismatch") + }) + } + }) + + t.Run("with session cookie", func(t *testing.T) { + testCases := []struct { + cookie *http.Cookie + expectedStatus int + }{ + { + cookie: &http.Cookie{ + Name: "id", + Value: session.Key, + HttpOnly: true, + }, + expectedStatus: http.StatusForbidden, + }, + { + cookie: &http.Cookie{ + Name: "id", + Value: "someInvalidSessionKey=", HttpOnly: true, }, expectedStatus: http.StatusUnauthorized, diff --git a/pkg/server/api/handlers/subscription.go b/pkg/server/api/handlers/subscription.go index 3d36ea8e..79476652 100644 --- a/pkg/server/api/handlers/subscription.go +++ b/pkg/server/api/handlers/subscription.go @@ -331,7 +331,7 @@ func (a *App) getSub(w http.ResponseWriter, r *http.Request) { resp.Items = append(resp.Items, i) } - respondJSON(w, resp) + respondJSON(w, http.StatusOK, resp) } // GetStripeSourceResponse is a response for getStripeToken @@ -345,7 +345,7 @@ type GetStripeSourceResponse struct { func respondWithEmptyStripeToken(w http.ResponseWriter) { var resp GetStripeSourceResponse - respondJSON(w, resp) + respondJSON(w, http.StatusOK, resp) } // getStripeCard retrieves card information from stripe and returns a stripe.Card @@ -506,7 +506,7 @@ func (a *App) getStripeSource(w http.ResponseWriter, r *http.Request) { ExpYear: cd.ExpYear, } - respondJSON(w, resp) + respondJSON(w, http.StatusOK, resp) } func (a *App) stripeWebhook(w http.ResponseWriter, req *http.Request) { diff --git a/pkg/server/api/handlers/user.go b/pkg/server/api/handlers/user.go index 8d096130..3077205a 100644 --- a/pkg/server/api/handlers/user.go +++ b/pkg/server/api/handlers/user.go @@ -122,7 +122,7 @@ func respondWithCalendar(w http.ResponseWriter, userID int) { payload[d.Format("2006-1-2")] = count } - respondJSON(w, payload) + respondJSON(w, http.StatusOK, payload) } func (a *App) getCalendar(w http.ResponseWriter, r *http.Request) { @@ -272,7 +272,7 @@ func (a *App) verifyEmail(w http.ResponseWriter, r *http.Request) { } session := makeSession(user, account) - respondJSON(w, session) + respondJSON(w, http.StatusOK, session) } type updateEmailPreferencePayload struct { @@ -321,7 +321,7 @@ func (a *App) updateEmailPreference(w http.ResponseWriter, r *http.Request) { tx.Commit() - respondJSON(w, frequency) + respondJSON(w, http.StatusOK, frequency) } func (a *App) getEmailPreference(w http.ResponseWriter, r *http.Request) { @@ -340,7 +340,7 @@ func (a *App) getEmailPreference(w http.ResponseWriter, r *http.Request) { } presented := presenters.PresentEmailPreference(pref) - respondJSON(w, presented) + respondJSON(w, http.StatusOK, presented) } type updatePasswordPayload struct { diff --git a/pkg/server/api/handlers/v3_auth_test.go b/pkg/server/api/handlers/v3_auth_test.go index d989c0da..c2d8ff75 100644 --- a/pkg/server/api/handlers/v3_auth_test.go +++ b/pkg/server/api/handlers/v3_auth_test.go @@ -118,6 +118,10 @@ func TestRegister(t *testing.T) { assert.Equal(t, user.StripeCustomerID, "", "StripeCustomerID mismatch") assert.Equal(t, user.MaxUSN, 0, "MaxUSN mismatch") + var repetitionRuleCount int + testutils.MustExec(t, db.Model(&database.RepetitionRule{}).Where("user_id = ?", account.UserID).Count(&repetitionRuleCount), "counting repetition rules") + assert.Equal(t, repetitionRuleCount, 1, "repetitionRuleCount mismatch") + // after register, should sign in user assertSessionResp(t, res) }) diff --git a/pkg/server/api/handlers/v3_books.go b/pkg/server/api/handlers/v3_books.go index df92c33e..d18e75cb 100644 --- a/pkg/server/api/handlers/v3_books.go +++ b/pkg/server/api/handlers/v3_books.go @@ -91,7 +91,7 @@ func (a *App) CreateBook(w http.ResponseWriter, r *http.Request) { resp := CreateBookResp{ Book: presenters.PresentBook(book), } - respondJSON(w, resp) + respondJSON(w, http.StatusCreated, resp) } // BooksOptions is a handler for OPTIONS endpoint for notes @@ -129,7 +129,7 @@ func respondWithBooks(userID int, query url.Values, w http.ResponseWriter) { } presentedBooks := presenters.PresentBooks(books) - respondJSON(w, presentedBooks) + respondJSON(w, http.StatusOK, presentedBooks) } // GetDemoBooks returns books for demo @@ -182,7 +182,7 @@ func (a *App) GetBook(w http.ResponseWriter, r *http.Request) { } p := presenters.PresentBook(book) - respondJSON(w, p) + respondJSON(w, http.StatusOK, p) } type updateBookPayload struct { @@ -231,7 +231,7 @@ func (a *App) UpdateBook(w http.ResponseWriter, r *http.Request) { resp := UpdateBookResp{ Book: presenters.PresentBook(book), } - respondJSON(w, resp) + respondJSON(w, http.StatusOK, resp) } // DeleteBookResp is the response from create book api @@ -283,5 +283,5 @@ func (a *App) DeleteBook(w http.ResponseWriter, r *http.Request) { Status: http.StatusOK, Book: presenters.PresentBook(b), } - respondJSON(w, resp) + respondJSON(w, http.StatusOK, resp) } diff --git a/pkg/server/api/handlers/v3_books_test.go b/pkg/server/api/handlers/v3_books_test.go index e20a5c76..cd7d4bc9 100644 --- a/pkg/server/api/handlers/v3_books_test.go +++ b/pkg/server/api/handlers/v3_books_test.go @@ -373,7 +373,7 @@ func TestCreateBook(t *testing.T) { res := testutils.HTTPAuthDo(t, req, user) // Test - assert.StatusCodeEquals(t, res, http.StatusOK, "") + assert.StatusCodeEquals(t, res, http.StatusCreated, "") var bookRecord database.Book var userRecord database.User diff --git a/pkg/server/api/handlers/v3_notes.go b/pkg/server/api/handlers/v3_notes.go index 85dba132..cb3e7612 100644 --- a/pkg/server/api/handlers/v3_notes.go +++ b/pkg/server/api/handlers/v3_notes.go @@ -101,7 +101,7 @@ func (a *App) UpdateNote(w http.ResponseWriter, r *http.Request) { Status: http.StatusOK, Result: presenters.PresentNote(note), } - respondJSON(w, resp) + respondJSON(w, http.StatusOK, resp) } type deleteNoteResp struct { @@ -143,7 +143,7 @@ func (a *App) DeleteNote(w http.ResponseWriter, r *http.Request) { Status: http.StatusNoContent, Result: presenters.PresentNote(n), } - respondJSON(w, resp) + respondJSON(w, http.StatusOK, resp) } type createNotePayload struct { @@ -207,7 +207,7 @@ func (a *App) CreateNote(w http.ResponseWriter, r *http.Request) { resp := CreateNoteResp{ Result: presenters.PresentNote(note), } - respondJSON(w, resp) + respondJSON(w, http.StatusCreated, resp) } // NotesOptions is a handler for OPTIONS endpoint for notes diff --git a/pkg/server/api/handlers/v3_notes_test.go b/pkg/server/api/handlers/v3_notes_test.go index efa4410d..912b6c14 100644 --- a/pkg/server/api/handlers/v3_notes_test.go +++ b/pkg/server/api/handlers/v3_notes_test.go @@ -60,7 +60,7 @@ func TestCreateNote(t *testing.T) { res := testutils.HTTPAuthDo(t, req, user) // Test - assert.StatusCodeEquals(t, res, http.StatusOK, "") + assert.StatusCodeEquals(t, res, http.StatusCreated, "") var noteRecord database.Note var bookRecord database.Book diff --git a/pkg/server/api/handlers/v3_sync.go b/pkg/server/api/handlers/v3_sync.go index a6a60e94..197a2d39 100644 --- a/pkg/server/api/handlers/v3_sync.go +++ b/pkg/server/api/handlers/v3_sync.go @@ -271,7 +271,7 @@ func (a *App) GetSyncFragment(w http.ResponseWriter, r *http.Request) { response := GetSyncFragmentResp{ Fragment: fragment, } - respondJSON(w, response) + respondJSON(w, http.StatusOK, response) } // GetSyncStateResp represents a response from GetSyncFragment handler @@ -301,5 +301,5 @@ func (a *App) GetSyncState(w http.ResponseWriter, r *http.Request) { "resp": response, }).Info("getting sync state") - respondJSON(w, response) + respondJSON(w, http.StatusOK, response) } diff --git a/pkg/server/api/helpers/helpers.go b/pkg/server/api/helpers/helpers.go index ea14b8a7..93a47355 100644 --- a/pkg/server/api/helpers/helpers.go +++ b/pkg/server/api/helpers/helpers.go @@ -20,8 +20,8 @@ package helpers import ( "github.com/dnote/dnote/pkg/server/database" + "github.com/google/uuid" "github.com/pkg/errors" - "github.com/satori/go.uuid" ) const ( @@ -42,7 +42,19 @@ func GetDemoUserID() (int, error) { return result.UserID, nil } -// GenUUID generates a new uuid -func GenUUID() string { - return uuid.NewV4().String() +// GenUUID generates a new uuid v4 +func GenUUID() (string, error) { + id, err := uuid.NewRandom() + if err != nil { + return "", errors.Wrap(err, "generating uuid") + } + + return id.String(), nil +} + +// ValidateUUID validates the given uuid +func ValidateUUID(u string) bool { + _, err := uuid.Parse(u) + + return err == nil } diff --git a/pkg/server/api/operations/books.go b/pkg/server/api/operations/books.go index 874c4728..dcaadc62 100644 --- a/pkg/server/api/operations/books.go +++ b/pkg/server/api/operations/books.go @@ -37,8 +37,13 @@ func CreateBook(user database.User, clock clock.Clock, name string) (database.Bo return database.Book{}, errors.Wrap(err, "incrementing user max_usn") } + uuid, err := helpers.GenUUID() + if err != nil { + return database.Book{}, err + } + book := database.Book{ - UUID: helpers.GenUUID(), + UUID: uuid, UserID: user.ID, Label: name, AddedOn: clock.Now().UnixNano(), diff --git a/pkg/server/api/operations/notes.go b/pkg/server/api/operations/notes.go index 8d915c74..297124aa 100644 --- a/pkg/server/api/operations/notes.go +++ b/pkg/server/api/operations/notes.go @@ -52,8 +52,13 @@ func CreateNote(user database.User, clock clock.Clock, bookUUID, content string, noteEditedOn = *editedOn } + uuid, err := helpers.GenUUID() + if err != nil { + return database.Note{}, err + } + note := database.Note{ - UUID: helpers.GenUUID(), + UUID: uuid, BookUUID: bookUUID, UserID: user.ID, AddedOn: noteAddedOn, diff --git a/pkg/server/api/operations/users.go b/pkg/server/api/operations/users.go index 20b2f12f..f55a6437 100644 --- a/pkg/server/api/operations/users.go +++ b/pkg/server/api/operations/users.go @@ -74,17 +74,35 @@ func createEmailVerificaitonToken(user database.User, tx *gorm.DB) error { } func createEmailPreference(user database.User, tx *gorm.DB) error { - EmailPreference := database.EmailPreference{ - UserID: user.ID, - DigestWeekly: true, + p := database.EmailPreference{ + UserID: user.ID, } - if err := tx.Save(&EmailPreference).Error; err != nil { + if err := tx.Save(&p).Error; err != nil { return errors.Wrap(err, "inserting email preference") } return nil } +func createDefaultRepetitionRule(user database.User, tx *gorm.DB) error { + r := database.RepetitionRule{ + Title: "Default repetition - all bookx", + UserID: user.ID, + Enabled: true, + 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 CreateUser(email, password string) (database.User, error) { db := database.DBConn @@ -119,6 +137,10 @@ func 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 := TouchLastLoginAt(user, tx); err != nil { tx.Rollback() return database.User{}, errors.Wrap(err, "updating last login") diff --git a/pkg/server/api/presenters/book.go b/pkg/server/api/presenters/book.go new file mode 100644 index 00000000..99829e17 --- /dev/null +++ b/pkg/server/api/presenters/book.go @@ -0,0 +1,57 @@ +/* Copyright (C) 2019 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 . + */ + +package presenters + +import ( + "time" + + "github.com/dnote/dnote/pkg/server/database" +) + +// Book is a result of PresentBooks +type Book struct { + UUID string `json:"uuid"` + USN int `json:"usn"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Label string `json:"label"` +} + +// PresentBook presents a book +func PresentBook(book database.Book) Book { + return Book{ + UUID: book.UUID, + USN: book.USN, + CreatedAt: FormatTS(book.CreatedAt), + UpdatedAt: FormatTS(book.UpdatedAt), + Label: book.Label, + } +} + +// PresentBooks presents books +func PresentBooks(books []database.Book) []Book { + ret := []Book{} + + for _, book := range books { + p := PresentBook(book) + ret = append(ret, p) + } + + return ret +} diff --git a/pkg/server/api/presenters/digest.go b/pkg/server/api/presenters/digest.go new file mode 100644 index 00000000..a9505389 --- /dev/null +++ b/pkg/server/api/presenters/digest.go @@ -0,0 +1,60 @@ +/* Copyright (C) 2019 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 . + */ + +package presenters + +import ( + "time" + + "github.com/dnote/dnote/pkg/server/database" +) + +// Digest is a presented digest +type Digest struct { + UUID string `json:"uuid"` + Notes []Note `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PresentDigests presetns digests +func PresentDigests(digests []database.Digest) []Digest { + ret := []Digest{} + + for _, digest := range digests { + p := Digest{ + UUID: digest.UUID, + CreatedAt: digest.CreatedAt, + UpdatedAt: digest.UpdatedAt, + } + + ret = append(ret, p) + } + + return ret +} + +// PresentDigest presents a digest +func PresentDigest(digest database.Digest) Digest { + ret := Digest{ + UUID: digest.UUID, + Notes: PresentNotes(digest.Notes), + } + + return ret +} diff --git a/web/src/components/Settings/Settings.global.scss b/pkg/server/api/presenters/email_preference.go similarity index 55% rename from web/src/components/Settings/Settings.global.scss rename to pkg/server/api/presenters/email_preference.go index 01b0c1ca..61b70089 100644 --- a/web/src/components/Settings/Settings.global.scss +++ b/pkg/server/api/presenters/email_preference.go @@ -16,40 +16,28 @@ * along with Dnote. If not, see . */ -// .form-container { -// .actions { -// margin-top: 12px; -// } -// } -// -// .input-help { -// font-size: 1.3rem; -// color: gray; -// margin-top: 5px; -// } -// -// .input-group { -// .copy-button { -// border-width: 1px; -// background: transparent; -// border-color: #ced4da; -// color: #333744; -// width: 85px; -// -// &:hover { -// box-shadow: 0px 0px 4px 2px #eaeaea; -// background: #fbfbfb; -// } -// -// &.copied { -// } -// } -// } -// -// .danger-zone { -// margin-top: 26px; -// -// .danger-zone-heading { -// margin-bottom: 18px; -// } -// } +package presenters + +import ( + "time" + + "github.com/dnote/dnote/pkg/server/database" +) + +// EmailPreference is a presented email digest +type EmailPreference struct { + DigestWeekly bool `json:"digest_weekly"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PresentEmailPreference presents a digest +func PresentEmailPreference(p database.EmailPreference) EmailPreference { + ret := EmailPreference{ + DigestWeekly: p.DigestWeekly, + CreatedAt: FormatTS(p.CreatedAt), + UpdatedAt: FormatTS(p.UpdatedAt), + } + + return ret +} diff --git a/pkg/server/api/presenters/helpers.go b/pkg/server/api/presenters/helpers.go new file mode 100644 index 00000000..b6cd8841 --- /dev/null +++ b/pkg/server/api/presenters/helpers.go @@ -0,0 +1,29 @@ +/* Copyright (C) 2019 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 . + */ + +package presenters + +import ( + "time" +) + +// FormatTS rounds up the given timestamp to the microsecond +// so as to make the times in the responses consistent +func FormatTS(ts time.Time) time.Time { + return ts.UTC().Round(time.Microsecond) +} diff --git a/pkg/server/api/presenters/presenters.go b/pkg/server/api/presenters/note.go similarity index 50% rename from pkg/server/api/presenters/presenters.go rename to pkg/server/api/presenters/note.go index a1f7670f..4a8c62a7 100644 --- a/pkg/server/api/presenters/presenters.go +++ b/pkg/server/api/presenters/note.go @@ -24,44 +24,6 @@ import ( "github.com/dnote/dnote/pkg/server/database" ) -// FormatTS rounds up the given timestamp to the microsecond -// so as to make the times in the responses consistent -func FormatTS(ts time.Time) time.Time { - return ts.UTC().Round(time.Microsecond) -} - -// Book is a result of PresentBooks -type Book struct { - UUID string `json:"uuid"` - USN int `json:"usn"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Label string `json:"label"` -} - -// PresentBook presents a book -func PresentBook(book database.Book) Book { - return Book{ - UUID: book.UUID, - USN: book.USN, - CreatedAt: FormatTS(book.CreatedAt), - UpdatedAt: FormatTS(book.UpdatedAt), - Label: book.Label, - } -} - -// PresentBooks presents books -func PresentBooks(books []database.Book) []Book { - ret := []Book{} - - for _, book := range books { - p := PresentBook(book) - ret = append(ret, p) - } - - return ret -} - // Note is a result of PresentNote type Note struct { UUID string `json:"uuid"` @@ -121,56 +83,3 @@ func PresentNotes(notes []database.Note) []Note { return ret } - -// Digest is a presented digest -type Digest struct { - UUID string `json:"uuid"` - Notes []Note `json:"notes"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// PresentDigests presetns digests -func PresentDigests(digests []database.Digest) []Digest { - ret := []Digest{} - - for _, digest := range digests { - p := Digest{ - UUID: digest.UUID, - CreatedAt: digest.CreatedAt, - UpdatedAt: digest.UpdatedAt, - } - - ret = append(ret, p) - } - - return ret -} - -// PresentDigest presents a digest -func PresentDigest(digest database.Digest) Digest { - ret := Digest{ - UUID: digest.UUID, - Notes: PresentNotes(digest.Notes), - } - - return ret -} - -// EmailPreference is a presented email digest -type EmailPreference struct { - DigestWeekly bool `json:"digest_weekly"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// PresentEmailPreference presents a digest -func PresentEmailPreference(p database.EmailPreference) EmailPreference { - ret := EmailPreference{ - DigestWeekly: p.DigestWeekly, - CreatedAt: FormatTS(p.CreatedAt), - UpdatedAt: FormatTS(p.UpdatedAt), - } - - return ret -} diff --git a/pkg/server/api/presenters/repetition_rule.go b/pkg/server/api/presenters/repetition_rule.go new file mode 100644 index 00000000..e4a9904a --- /dev/null +++ b/pkg/server/api/presenters/repetition_rule.go @@ -0,0 +1,75 @@ +/* Copyright (C) 2019 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 . + */ + +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 +} diff --git a/pkg/server/api/presenters/repetition_rule_test.go b/pkg/server/api/presenters/repetition_rule_test.go new file mode 100644 index 00000000..70689707 --- /dev/null +++ b/pkg/server/api/presenters/repetition_rule_test.go @@ -0,0 +1,90 @@ +/* Copyright (C) 2019 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 . + */ + +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") + }) + } +} diff --git a/pkg/server/api/scripts/test.sh b/pkg/server/api/scripts/test.sh deleted file mode 100755 index 8029bba8..00000000 --- a/pkg/server/api/scripts/test.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -# test.sh runs api tests. It is to be invoked by other scripts that set -# appropriate env vars. -set -eux - -pushd "$GOPATH"/src/github.com/dnote/dnote/pkg/server/api -go test ./... -cover -p 1 -popd diff --git a/pkg/server/database/consts.go b/pkg/server/database/consts.go new file mode 100644 index 00000000..7d815c67 --- /dev/null +++ b/pkg/server/database/consts.go @@ -0,0 +1,39 @@ +/* Copyright (C) 2019 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 . + */ + +package database + +const ( + // TokenTypeResetPassword is a type of a token for reseting password + TokenTypeResetPassword = "reset_password" + // TokenTypeEmailVerification is a type of a token for verifying email + TokenTypeEmailVerification = "email_verification" + // TokenTypeEmailPreference is a type of a token for updating email preference + TokenTypeEmailPreference = "email_preference" + // TokenTypeRepetition is a type of a token for viewing and editing repetition rules + TokenTypeRepetition = "repetition_rules" +) + +const ( + // BookDomainAll incidates that all books are eligible to be the source books + BookDomainAll = "all" + // BookDomainIncluding incidates that some specified books are eligible to be the source books + BookDomainIncluding = "including" + // BookDomainExluding incidates that all books except for some specified books are eligible to be the source books + BookDomainExluding = "excluding" +) diff --git a/pkg/server/database/database.go b/pkg/server/database/database.go index b3f31b36..53efa459 100644 --- a/pkg/server/database/database.go +++ b/pkg/server/database/database.go @@ -100,15 +100,6 @@ var ( DBConn *gorm.DB ) -const ( - // TokenTypeResetPassword is a type of a token for reseting password - TokenTypeResetPassword = "reset_password" - // TokenTypeEmailVerification is a type of a token for verifying email - TokenTypeEmailVerification = "email_verification" - // TokenTypeEmailPreference is a type of a token for updating email preference - TokenTypeEmailPreference = "email_preference" -) - // Open opens the connection with the database func Open(c Config) { connStr, err := getPGConnectionString(c) @@ -143,6 +134,7 @@ func InitSchema() { EmailPreference{}, Session{}, Digest{}, + RepetitionRule{}, ).Error; err != nil { panic(err) } diff --git a/pkg/server/database/migrations/20191028103522-create-weekly-repetition.sql b/pkg/server/database/migrations/20191028103522-create-weekly-repetition.sql new file mode 100644 index 00000000..c164cd7a --- /dev/null +++ b/pkg/server/database/migrations/20191028103522-create-weekly-repetition.sql @@ -0,0 +1,45 @@ +-- create-weekly-repetition.sql creates the default repetition rules for the users +-- that used to have the weekly email digest on Friday 20:00 UTC + +-- +migrate Up + +WITH next_friday AS ( + SELECT * FROM generate_series( + CURRENT_DATE + INTERVAL '1 day', + CURRENT_DATE + INTERVAL '7 days', + INTERVAL '1 day' + ) AS day +) +INSERT INTO repetition_rules +( + user_id, + title, + enabled, + hour, + minute, + frequency, + last_active, + book_domain, + note_count, + next_active +) SELECT + t1.id, + 'Default weekly repetition', + true, + 20, + 0, + 604800000, -- 7 days + 0, + 'all', + 20, + extract(epoch FROM date_trunc('day', t1.day) + INTERVAL '20 hours') * 1000 +FROM ( + SELECT * FROM users + INNER JOIN next_friday ON EXTRACT(ISODOW FROM day) = '5' -- next friday + WHERE users.cloud = true +) as t1; + + +-- +migrate Down + +DELETE FROM repetition_rules WHERE title = 'Default weekly repetition' AND enabled AND hour = 8 AND minute = 0; diff --git a/pkg/server/database/models.go b/pkg/server/database/models.go index 2937ce1e..8e084f0a 100644 --- a/pkg/server/database/models.go +++ b/pkg/server/database/models.go @@ -24,7 +24,7 @@ import ( // Model is the base model definition type Model struct { - ID int `gorm:"primary_key" json:"id"` + ID int `gorm:"primary_key" json:"-"` CreatedAt time.Time `json:"created_at" gorm:"default:now()"` UpdatedAt time.Time `json:"updated_at"` } @@ -113,7 +113,7 @@ type Notification struct { type EmailPreference struct { Model UserID int `gorm:"index" json:"-"` - DigestWeekly bool `json:"digest_weekly"` + DigestWeekly bool `json:"digest_weekly"` // Deprecated: email digests now sends based on the repetition rule } // Session represents a user session @@ -128,8 +128,29 @@ type Session struct { // Digest is a digest of notes type Digest struct { UUID string `json:"uuid" gorm:"primary_key:true;type:uuid;index;default:uuid_generate_v4()"` + RuleID int `gorm:"index"` UserID int `gorm:"index"` Notes []Note `gorm:"many2many:digest_notes;association_foreignKey:uuid;association_jointable_foreignkey:note_uuid;jointable_foreignkey:digest_uuid;"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +// 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"` +} diff --git a/pkg/server/job/digest.go b/pkg/server/job/digest.go deleted file mode 100644 index a6a3e2e4..00000000 --- a/pkg/server/job/digest.go +++ /dev/null @@ -1,151 +0,0 @@ -/* Copyright (C) 2019 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 . - */ - -package job - -import ( - "log" - "time" - - "github.com/dnote/dnote/pkg/server/database" - "github.com/dnote/dnote/pkg/server/mailer" - "github.com/pkg/errors" -) - -// MakeDigest builds a weekly digest email -func MakeDigest(user database.User, emailAddr string) (*mailer.Email, error) { - log.Printf("Sending for %s", emailAddr) - db := database.DBConn - - subject := "Weekly Digest" - tok, err := mailer.GetEmailPreferenceToken(user) - if err != nil { - return nil, errors.Wrap(err, "getting email frequency token") - } - - now := time.Now() - threshold1 := int(now.AddDate(0, 0, -1).UnixNano()) - threshold2 := int(now.AddDate(0, 0, -3).UnixNano()) - threshold3 := int(now.AddDate(0, 0, -7).UnixNano()) - - var stage1 []database.Note - var stage2 []database.Note - var stage3 []database.Note - - // TODO: ordering by random() does not scale if table grows large - if err := db.Where("user_id = ? AND added_on > ? AND added_on < ?", user.ID, threshold2, threshold1).Order("random()").Limit(4).Preload("Book").Find(&stage1).Error; err != nil { - return nil, errors.Wrap(err, "Failed to get notes with threshold 1") - } - if err := db.Where("user_id = ? AND added_on > ? AND added_on < ?", user.ID, threshold3, threshold2).Order("random()").Limit(4).Preload("Book").Find(&stage2).Error; err != nil { - return nil, errors.Wrap(err, "Failed to get notes with threshold 2") - } - if err := db.Where("user_id = ? AND added_on < ?", user.ID, threshold3).Order("random()").Limit(4).Preload("Book").Find(&stage3).Error; err != nil { - return nil, errors.Wrap(err, "Failed to get notes with threshold 3") - } - - noteInfos := []mailer.DigestNoteInfo{} - for _, note := range stage1 { - info := mailer.NewNoteInfo(note, 1) - noteInfos = append(noteInfos, info) - } - for _, note := range stage2 { - info := mailer.NewNoteInfo(note, 2) - noteInfos = append(noteInfos, info) - } - for _, note := range stage3 { - info := mailer.NewNoteInfo(note, 3) - noteInfos = append(noteInfos, info) - } - - notes := append(append(stage1, stage2...), stage3...) - digest := database.Digest{ - UserID: user.ID, - Notes: notes, - } - if err := db.Save(&digest).Error; err != nil { - return nil, errors.Wrap(err, "saving digest") - } - - bookCount := 0 - bookMap := map[string]bool{} - for _, n := range notes { - if ok := bookMap[n.Book.Label]; !ok { - bookCount++ - bookMap[n.Book.Label] = true - } - } - - tmplData := mailer.DigestTmplData{ - Subject: subject, - NoteInfo: noteInfos, - ActiveBookCount: bookCount, - ActiveNoteCount: len(notes), - EmailSessionToken: tok.Value, - } - - email := mailer.NewEmail("notebot@getdnote.com", []string{emailAddr}, subject) - if err := email.ParseTemplate(mailer.EmailTypeWeeklyDigest, tmplData); err != nil { - return nil, err - } - - return email, nil -} - -// sendDigest sends the weekly digests to users -func sendDigest() error { - db := database.DBConn - - var users []database.User - if err := db. - Preload("Account"). - Where("cloud = ?", true). - Find(&users).Error; err != nil { - return err - } - - for _, user := range users { - account := user.Account - - if !account.Email.Valid || !account.EmailVerified { - continue - } - - email, err := MakeDigest(user, account.Email.String) - if err != nil { - log.Printf("Error occurred while sending to %s: %s", account.Email.String, err.Error()) - continue - } - - err = email.Send() - if err != nil { - log.Printf("Error occurred while sending to %s: %s", account.Email.String, err.Error()) - continue - } - - notif := database.Notification{ - Type: "email_weekly", - UserID: user.ID, - } - - if err := db.Create(¬if).Error; err != nil { - log.Printf("Error occurred while creating notification for %s: %s", account.Email.String, err.Error()) - } - } - - return nil -} diff --git a/pkg/server/job/job.go b/pkg/server/job/job.go index 82a63de0..56085e8d 100644 --- a/pkg/server/job/job.go +++ b/pkg/server/job/job.go @@ -21,6 +21,8 @@ package job import ( "log" + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/job/repetition" "github.com/pkg/errors" "github.com/robfig/cron" ) @@ -38,9 +40,11 @@ func scheduleJob(c *cron.Cron, spec string, cmd func()) { func Run() { log.Println("Started background tasks") + cl := clock.New() + // Schedule jobs c := cron.New() - scheduleJob(c, "0 20 * * 5", func() { sendDigest() }) + scheduleJob(c, "* * * * *", func() { repetition.Do(cl) }) c.Start() // Block forever diff --git a/pkg/server/job/repetition/repetition.go b/pkg/server/job/repetition/repetition.go new file mode 100644 index 00000000..b0df34e1 --- /dev/null +++ b/pkg/server/job/repetition/repetition.go @@ -0,0 +1,252 @@ +/* Copyright (C) 2019 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 . + */ + +package repetition + +import ( + "fmt" + "time" + + "github.com/dnote/dnote/pkg/clock" + "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" +) + +// BuildEmail builds an email for the spaced repetition +func BuildEmail(now time.Time, user database.User, emailAddr string, digest database.Digest, rule database.RepetitionRule) (*mailer.Email, error) { + date := now.Format("Jan 02 2006") + subject := fmt.Sprintf("%s %s", rule.Title, date) + tok, err := mailer.GetToken(user, database.TokenTypeRepetition) + if err != nil { + return nil, errors.Wrap(err, "getting email frequency token") + } + + t1 := now.AddDate(0, 0, -3).UnixNano() + t2 := now.AddDate(0, 0, -7).UnixNano() + + noteInfos := []mailer.DigestNoteInfo{} + for _, note := range 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 digest.Notes { + if ok := bookMap[n.Book.Label]; !ok { + bookCount++ + bookMap[n.Book.Label] = true + } + } + + tmplData := mailer.DigestTmplData{ + Subject: subject, + NoteInfo: noteInfos, + ActiveBookCount: bookCount, + ActiveNoteCount: len(digest.Notes), + EmailSessionToken: tok.Value, + RuleUUID: rule.UUID, + RuleTitle: rule.Title, + } + + email := mailer.NewEmail("noreply@getdnote.com", []string{emailAddr}, subject) + if err := email.ParseTemplate(mailer.EmailTypeWeeklyDigest, tmplData); err != nil { + return nil, err + } + + return email, nil +} + +func getEligibleRules(now time.Time) ([]database.RepetitionRule, error) { + hour := now.Hour() + minute := now.Minute() + + var ret []database.RepetitionRule + db := database.DBConn + if err := 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 := database.Digest{ + RuleID: rule.ID, + UserID: rule.UserID, + Notes: notes, + } + if err := tx.Save(&digest).Error; err != nil { + return database.Digest{}, errors.Wrap(err, "saving digest") + } + + return digest, nil +} + +func notify(now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error { + db := database.DBConn + + var account database.Account + if err := 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 + } + + email, err := BuildEmail(now, user, account.Email.String, digest, rule) + if err != nil { + return errors.Wrap(err, "making email") + } + + err = email.Send() + if err != nil { + return errors.Wrap(err, "sending email") + } + + notif := database.Notification{ + Type: "email_weekly", + UserID: user.ID, + } + + if err := db.Create(¬if).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 touchTimestamp(tx *gorm.DB, rule database.RepetitionRule, now time.Time) error { + lastActive := rule.NextActive + + rule.LastActive = lastActive + rule.NextActive = lastActive + rule.Frequency + + if err := tx.Save(&rule).Error; err != nil { + return errors.Wrap(err, "updating repetition rule") + } + + return nil +} + +func process(now time.Time, rule database.RepetitionRule) error { + log.WithFields(log.Fields{ + "uuid": rule.UUID, + }).Info("processing repetition") + + db := database.DBConn + tx := db.Begin() + + if !checkCooldown(now, rule) { + 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 := 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 +} + +// Do creates spaced repetitions and delivers the results based on the rules +func Do(c clock.Clock) error { + now := c.Now().UTC() + + rules, err := getEligibleRules(now) + if err != nil { + return 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 { + if err := process(now, rule); err != nil { + log.WithFields(log.Fields{ + "rule uuid": rule.UUID, + }).ErrorWrap(err, "Could not process the repetition rule") + continue + } + } + + return nil +} diff --git a/pkg/server/job/repetition/repetition_test.go b/pkg/server/job/repetition/repetition_test.go new file mode 100644 index 00000000..6eda6fc3 --- /dev/null +++ b/pkg/server/job/repetition/repetition_test.go @@ -0,0 +1,384 @@ +package repetition + +import ( + "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/testutils" +) + +func init() { + testutils.InitTestDB() +} + +func assertLastActive(t *testing.T, ruleUUID string, lastActive int64) { + db := database.DBConn + + var rule database.RepetitionRule + testutils.MustExec(t, db.Where("uuid = ?", ruleUUID).First(&rule), "finding rule1") + + assert.Equal(t, rule.LastActive, lastActive, "LastActive mismatch") +} + +func assertRepetitionCount(t *testing.T, rule database.RepetitionRule, expected int) { + db := database.DBConn + + var digestCount int + testutils.MustExec(t, 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 TestDo(t *testing.T) { + t.Run("processes the rule on time", func(t *testing.T) { + defer testutils.ClearData() + + // Set up + user := testutils.SetupUserData() + 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, + }, + } + + db := database.DBConn + testutils.MustExec(t, db.Save(&r1), "preparing rule1") + + c := clock.NewMock() + + // Test + // 1 day later + c.SetNow(time.Date(2009, time.November, 2, 12, 2, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(0)) + assertRepetitionCount(t, r1, 0) + + // 2 days later + c.SetNow(time.Date(2009, time.November, 3, 12, 2, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(0)) + assertRepetitionCount(t, r1, 0) + + // 3 days later - should be processed + c.SetNow(time.Date(2009, time.November, 4, 12, 1, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(0)) + assertRepetitionCount(t, r1, 0) + + c.SetNow(time.Date(2009, time.November, 4, 12, 2, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(1257336120000)) + assertRepetitionCount(t, r1, 1) + + c.SetNow(time.Date(2009, time.November, 4, 12, 3, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(1257336120000)) + assertRepetitionCount(t, r1, 1) + + // 4 day later + c.SetNow(time.Date(2009, time.November, 5, 12, 2, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(1257336120000)) + assertRepetitionCount(t, r1, 1) + // 5 days later + c.SetNow(time.Date(2009, time.November, 6, 12, 2, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(1257336120000)) + assertRepetitionCount(t, r1, 1) + // 6 days later - should be processed + c.SetNow(time.Date(2009, time.November, 7, 12, 2, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(1257595320000)) + assertRepetitionCount(t, r1, 2) + // 7 days later + c.SetNow(time.Date(2009, time.November, 8, 12, 2, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(1257595320000)) + assertRepetitionCount(t, r1, 2) + // 8 days later + c.SetNow(time.Date(2009, time.November, 9, 12, 2, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(1257595320000)) + assertRepetitionCount(t, r1, 2) + // 9 days later - should be processed + c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC)) + Do(c) + assertLastActive(t, r1.UUID, int64(1257854520000)) + assertRepetitionCount(t, r1, 3) + }) +} + +func TestDo_Disabled(t *testing.T) { + defer testutils.ClearData() + + // Set up + user := testutils.SetupUserData() + 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, + }, + } + + db := database.DBConn + testutils.MustExec(t, db.Save(&r1), "preparing rule1") + + // Execute + c := clock.NewMock() + c.SetNow(time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)) + Do(c) + + // Test + assertLastActive(t, r1.UUID, int64(0)) + assertRepetitionCount(t, r1, 0) +} + +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 { + db := database.DBConn + user := testutils.SetupUserData() + + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, db.Save(&b1), "preparing b1") + b2 := database.Book{ + UserID: user.ID, + Label: "css", + } + testutils.MustExec(t, db.Save(&b2), "preparing b2") + b3 := database.Book{ + UserID: user.ID, + Label: "golang", + } + testutils.MustExec(t, db.Save(&b3), "preparing b3") + + n1 := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + } + testutils.MustExec(t, db.Save(&n1), "preparing n1") + n2 := database.Note{ + UserID: user.ID, + BookUUID: b2.UUID, + } + testutils.MustExec(t, db.Save(&n2), "preparing n2") + n3 := database.Note{ + UserID: user.ID, + BookUUID: b3.UUID, + } + testutils.MustExec(t, 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() + + db := database.DBConn + t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC) + t1 := time.Date(2009, time.November, 8, 12, 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, db.Save(&r1), "preparing rule1") + + // Execute + c := clock.NewMock() + + c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC)) + Do(c) + + // Test + assertLastActive(t, r1.UUID, int64(1257681600000)) + assertRepetitionCount(t, r1, 1) + + var repetition database.Digest + testutils.MustExec(t, 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, db.Where("uuid = ?", dat.Note1.UUID).First(&n1Record), "finding n1") + testutils.MustExec(t, db.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2") + testutils.MustExec(t, 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() + + db := database.DBConn + t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC) + t1 := time.Date(2009, time.November, 8, 12, 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, db.Save(&r1), "preparing rule1") + + // Execute + c := clock.NewMock() + + c.SetNow(time.Date(2009, time.November, 8, 21, 0, 1, 0, time.UTC)) + Do(c) + + // Test + assertLastActive(t, r1.UUID, int64(1257681600000)) + assertRepetitionCount(t, r1, 1) + + var repetition database.Digest + testutils.MustExec(t, 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, db.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2") + testutils.MustExec(t, 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() + + db := database.DBConn + t0 := time.Date(2009, time.November, 1, 12, 0, 0, 0, time.UTC) + t1 := time.Date(2009, time.November, 8, 12, 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, db.Save(&r1), "preparing rule1") + + // Execute + c := clock.NewMock() + + c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC)) + Do(c) + + // Test + assertLastActive(t, r1.UUID, int64(1257681600000)) + assertRepetitionCount(t, r1, 1) + + var repetition database.Digest + testutils.MustExec(t, 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, db.Where("uuid = ?", dat.Note1.UUID).First(&n1Record), "finding n1") + testutils.MustExec(t, db.Where("uuid = ?", dat.Note2.UUID).First(&n2Record), "finding n2") + expected := []database.Note{n1Record, n2Record} + assert.DeepEqual(t, repetition.Notes, expected, "result mismatch") + }) +} diff --git a/pkg/server/job/repetition/strategy.go b/pkg/server/job/repetition/strategy.go new file mode 100644 index 00000000..7d340361 --- /dev/null +++ b/pkg/server/job/repetition/strategy.go @@ -0,0 +1,141 @@ +/* Copyright (C) 2019 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 . + */ + +package repetition + +import ( + "sort" + "time" + + "github.com/dnote/dnote/pkg/server/database" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +func getRuleBookIDs(ruleID int) ([]int, error) { + db := database.DBConn + 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(noteQuery *gorm.DB, rule database.RepetitionRule) (*gorm.DB, error) { + ret := noteQuery + + if rule.BookDomain != database.BookDomainAll { + bookIDs, err := getRuleBookIDs(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(conn *gorm.DB, rule database.RepetitionRule, dst *[]database.Note) error { + c, err := applyBookDomain(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() + + // Get notes into three buckets with different threshold values + var stage1 []database.Note + var stage2 []database.Note + var stage3 []database.Note + if err := getNotes(db.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.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.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 +} diff --git a/pkg/server/job/repetition/strategy_test.go b/pkg/server/job/repetition/strategy_test.go new file mode 100644 index 00000000..07ed7d92 --- /dev/null +++ b/pkg/server/job/repetition/strategy_test.go @@ -0,0 +1,114 @@ +/* Copyright (C) 2019 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 . + */ + +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() + + db := database.DBConn + + user := testutils.SetupUserData() + b1 := database.Book{ + UserID: user.ID, + Label: "js", + } + testutils.MustExec(t, db.Save(&b1), "preparing b1") + b2 := database.Book{ + UserID: user.ID, + Label: "css", + } + testutils.MustExec(t, db.Save(&b2), "preparing b2") + b3 := database.Book{ + UserID: user.ID, + Label: "golang", + } + testutils.MustExec(t, db.Save(&b3), "preparing b3") + + n1 := database.Note{ + UserID: user.ID, + BookUUID: b1.UUID, + } + testutils.MustExec(t, db.Save(&n1), "preparing n1") + n2 := database.Note{ + UserID: user.ID, + BookUUID: b2.UUID, + } + testutils.MustExec(t, db.Save(&n2), "preparing n2") + n3 := database.Note{ + UserID: user.ID, + BookUUID: b3.UUID, + } + testutils.MustExec(t, db.Save(&n3), "preparing n3") + + var n1Record, n2Record, n3Record database.Note + testutils.MustExec(t, db.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1") + testutils.MustExec(t, db.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2") + testutils.MustExec(t, 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(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, db.Save(&rule), "preparing rule") + + conn, err := applyBookDomain(db.Debug(), 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") + }) +} diff --git a/pkg/server/mailer/mailer.go b/pkg/server/mailer/mailer.go index 8c1f286d..0c9187f2 100644 --- a/pkg/server/mailer/mailer.go +++ b/pkg/server/mailer/mailer.go @@ -46,7 +46,7 @@ var ( // EmailTypeResetPassword represents a reset password email EmailTypeResetPassword = "reset_password" // EmailTypeWeeklyDigest represents a weekly digest email - EmailTypeWeeklyDigest = "weekly_digest" + EmailTypeWeeklyDigest = "digest" // EmailTypeEmailVerification represents an email verification email EmailTypeEmailVerification = "email_verification" ) @@ -124,12 +124,6 @@ func NewEmail(from string, to []string, subject string) *Email { } } -// isWhitelisted checks if the email is safe to send in non production env -func isWhitelisted(emails []string) bool { - return false - // return len(emails) == 1 && emails[0] == "mikeswcho@gmail.com" -} - // Send sends the email func (e *Email) Send() error { // If not production, never actually send an email diff --git a/pkg/server/mailer/templates/README.md b/pkg/server/mailer/templates/README.md index 6137fb08..9329442a 100644 --- a/pkg/server/mailer/templates/README.md +++ b/pkg/server/mailer/templates/README.md @@ -3,4 +3,11 @@ Email templates * `/src` contains templates. -* Run the server to develop templates locally. + +## Development + +Run the server to develop templates locally. + +``` +./dev.sh +``` diff --git a/pkg/server/mailer/templates/dev.sh b/pkg/server/mailer/templates/dev.sh new file mode 100755 index 00000000..035220b1 --- /dev/null +++ b/pkg/server/mailer/templates/dev.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -eux + +PID="" + +function cleanup { + if [ "$PID" != "" ]; then + kill "$PID" + fi +} +trap cleanup EXIT + +while true; do + go build main.go + ./main & + PID=$! + inotifywait -r -e modify . + kill $PID +done + + diff --git a/pkg/server/mailer/templates/main.go b/pkg/server/mailer/templates/main.go index 43459976..867d0d8f 100644 --- a/pkg/server/mailer/templates/main.go +++ b/pkg/server/mailer/templates/main.go @@ -22,25 +22,46 @@ import ( "log" "net/http" "os" + "time" "github.com/dnote/dnote/pkg/server/database" - "github.com/dnote/dnote/pkg/server/job" + "github.com/dnote/dnote/pkg/server/job/repetition" "github.com/dnote/dnote/pkg/server/mailer" "github.com/joho/godotenv" _ "github.com/lib/pq" "github.com/pkg/errors" ) -func weeklyDigestHandler(w http.ResponseWriter, r *http.Request) { +func digestHandler(w http.ResponseWriter, r *http.Request) { db := database.DBConn + q := r.URL.Query() + digestUUID := q.Get("digest_uuid") + if digestUUID == "" { + http.Error(w, errors.New("Please provide digest_uuid query param").Error(), http.StatusBadRequest) + return + } + var user database.User if err := db.First(&user).Error; err != nil { http.Error(w, errors.Wrap(err, "Failed to find user").Error(), http.StatusInternalServerError) return } - email, err := job.MakeDigest(user, "sung@getdnote.com") + var digest database.Digest + if err := db.Where("uuid = ?", digestUUID).Preload("Notes").First(&digest).Error; err != nil { + http.Error(w, errors.Wrap(err, "finding digest").Error(), http.StatusInternalServerError) + return + } + + var rule database.RepetitionRule + if err := db.Where("id = ?", digest.RuleID).First(&rule).Error; err != nil { + http.Error(w, errors.Wrap(err, "finding digest").Error(), http.StatusInternalServerError) + return + } + + now := time.Now() + email, err := repetition.BuildEmail(now, user, "sung@getdnote.com", digest, rule) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -69,6 +90,10 @@ func emailVerificationHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) } +func homeHandler(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Email development server is running.")) +} + func init() { err := godotenv.Load(".env.dev") if err != nil { @@ -89,9 +114,10 @@ func main() { mailer.InitTemplates(nil) - log.Println("Email template debug server running on http://127.0.0.1:2300") + log.Println("Email template development server running on http://127.0.0.1:2300") - http.HandleFunc("/weekly-digest", weeklyDigestHandler) + http.HandleFunc("/", homeHandler) + http.HandleFunc("/digest", digestHandler) http.HandleFunc("/email-verification", emailVerificationHandler) log.Fatal(http.ListenAndServe(":2300", nil)) } diff --git a/pkg/server/mailer/templates/src/weekly_digest.html b/pkg/server/mailer/templates/src/digest.html similarity index 94% rename from pkg/server/mailer/templates/src/weekly_digest.html rename to pkg/server/mailer/templates/src/digest.html index 7393d82d..bb7db9d7 100644 --- a/pkg/server/mailer/templates/src/weekly_digest.html +++ b/pkg/server/mailer/templates/src/digest.html @@ -288,6 +288,7 @@ .note { padding: 6px 9px; border-radius: 2px; + word-break: break-word; } .timeago { font-weight: 600; @@ -340,16 +341,18 @@ .notes-container { padding: 12px 0; } + + .rule-title { + font-weight: 600; + } - -
- Here is your weekly Dnote digest. + Here is your automated spaced repetition. {{ template "header" }} @@ -361,7 +364,15 @@ + + + + @@ -417,7 +428,7 @@ @@ -425,7 +436,7 @@
- This is your weekly Dnote digest, featuring {{ .ActiveNoteCount }} notes from {{ .ActiveBookCount }} books. + This is your Dnote spaced repetition. +
+ + {{ .RuleTitle }} +
Dnote -
Level 2, 11 York St, Sydney NSW 2000 +
11 York St, Level 2, Sydney, NSW 2000, Australia
- Change email frequency + Change digest settings
diff --git a/pkg/server/mailer/templates/src/footer.html b/pkg/server/mailer/templates/src/footer.html index 535a46c7..f75185a3 100644 --- a/pkg/server/mailer/templates/src/footer.html +++ b/pkg/server/mailer/templates/src/footer.html @@ -4,7 +4,7 @@
Dnote -
Level 2, 11 York St, Sydney NSW 2000 +
11 York St, Level 2, Sydney, NSW 2000, Australia
diff --git a/pkg/server/mailer/utils.go b/pkg/server/mailer/tokens.go similarity index 84% rename from pkg/server/mailer/utils.go rename to pkg/server/mailer/tokens.go index e0510291..6a01ae68 100644 --- a/pkg/server/mailer/utils.go +++ b/pkg/server/mailer/tokens.go @@ -37,14 +37,14 @@ func generateRandomToken(bits int) (string, error) { return base64.URLEncoding.EncodeToString(b), nil } -// GetEmailPreferenceToken returns an unused email frequency token for the user -// by first looking up any existing record and creating one if none exists. -func GetEmailPreferenceToken(user database.User) (database.Token, error) { +// GetToken returns an token of the given kind for the user +// by first looking up any unused record and creating one if none exists. +func GetToken(user database.User, kind string) (database.Token, error) { db := database.DBConn var tok database.Token conn := db. - Where("user_id = ? AND type =? AND used_at IS NULL", user.ID, database.TokenTypeEmailPreference). + Where("user_id = ? AND type =? AND used_at IS NULL", user.ID, kind). First(&tok) tokenVal, err := generateRandomToken(16) @@ -55,7 +55,7 @@ func GetEmailPreferenceToken(user database.User) (database.Token, error) { if conn.RecordNotFound() { tok = database.Token{ UserID: user.ID, - Type: database.TokenTypeEmailPreference, + Type: kind, Value: tokenVal, } if err := db.Save(&tok).Error; err != nil { diff --git a/pkg/server/mailer/types.go b/pkg/server/mailer/types.go index d677579a..0555aa08 100644 --- a/pkg/server/mailer/types.go +++ b/pkg/server/mailer/types.go @@ -41,6 +41,8 @@ type DigestTmplData struct { ActiveBookCount int ActiveNoteCount int EmailSessionToken string + RuleUUID string + RuleTitle string } // NewNoteInfo returns a new NoteInfo diff --git a/pkg/server/api/scripts/test-local.sh b/pkg/server/scripts/test-local.sh similarity index 86% rename from pkg/server/api/scripts/test-local.sh rename to pkg/server/scripts/test-local.sh index 6dda98a0..2725a30e 100755 --- a/pkg/server/api/scripts/test-local.sh +++ b/pkg/server/scripts/test-local.sh @@ -9,4 +9,4 @@ set -a source "$basePath/.env.test" set +a -"$basePath/api/scripts/test.sh" +"$basePath/scripts/test.sh" diff --git a/pkg/server/scripts/test.sh b/pkg/server/scripts/test.sh new file mode 100755 index 00000000..5d81e34f --- /dev/null +++ b/pkg/server/scripts/test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# test.sh runs server tests. It is to be invoked by other scripts that set +# appropriate env vars. +set -eux + +pushd "$GOPATH"/src/github.com/dnote/dnote/pkg/server + +if [ "${WATCH-false}" == true ]; then + set +e + while inotifywait --exclude .swp -e modify -r .; do go test ./... -cover -p 1; done; + set -e +else + go test ./... -cover -p 1 +fi + +popd diff --git a/pkg/server/testutils/main.go b/pkg/server/testutils/main.go index 48b7f033..cacd6f1a 100644 --- a/pkg/server/testutils/main.go +++ b/pkg/server/testutils/main.go @@ -181,6 +181,9 @@ func ClearData() { if err := db.Delete(&database.Digest{}).Error; err != nil { panic(errors.Wrap(err, "Failed to clear digests")) } + if err := db.Delete(&database.RepetitionRule{}).Error; err != nil { + panic(errors.Wrap(err, "Failed to clear digests")) + } } // HTTPDo makes an HTTP request and returns a response diff --git a/web/.eslintrc b/web/.eslintrc index 0f9450f9..ee36d835 100644 --- a/web/.eslintrc +++ b/web/.eslintrc @@ -39,7 +39,8 @@ "react/jsx-one-expression-per-line": 0, "@typescript-eslint/no-unused-vars": 1, "import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*_test.ts"]}], - "lines-between-class-members": 0 + "lines-between-class-members": 0, + "react/jsx-fragments": 0 }, "plugins": [ "react", "react-hooks", "import", "prettier", "@typescript-eslint" diff --git a/web/assets/index.html b/web/assets/index.html index 4c7658e5..e87032ee 100644 --- a/web/assets/index.html +++ b/web/assets/index.html @@ -37,7 +37,7 @@
- +