├── .gitignore
├── Makefile
├── api.go
├── config.go
├── daily.templ
├── db.go
├── go.mod
├── go.sum
├── mailer.go
├── main.go
├── sendgrid.go
├── service.go
├── store.go
├── types.go
└── utils.go
/.gitignore:
--------------------------------------------------------------------------------
1 | .envrc
2 | bin/
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | run: build
2 | @./bin/notebase
3 |
4 | build:
5 | @go build -o bin/notebase
6 |
7 | test:
8 | @go test -v ./...
--------------------------------------------------------------------------------
/api.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "database/sql"
5 | "log"
6 | "net/http"
7 |
8 | "github.com/gorilla/mux"
9 | )
10 |
11 | type APIServer struct {
12 | addr string
13 | db *sql.DB
14 | }
15 |
16 | func NewAPIServer(addr string, db *sql.DB) *APIServer {
17 | return &APIServer{addr: addr, db: db}
18 | }
19 |
20 | func (s *APIServer) Run() {
21 | router := mux.NewRouter()
22 | subrouter := router.PathPrefix("/api/v1").Subrouter()
23 |
24 | store := NewStore(s.db)
25 | mailer := NewSendGridMailer(Envs.SendGridAPIKey, Envs.SendGridFromEmail)
26 |
27 | service := NewService(store, mailer)
28 | service.RegisterRoutes(subrouter)
29 |
30 | log.Println("Starting API server on ", s.addr)
31 |
32 | log.Fatal(http.ListenAndServe(s.addr, subrouter))
33 | }
34 |
--------------------------------------------------------------------------------
/config.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 | )
6 |
7 | var Envs = initConfig()
8 |
9 | type Config struct {
10 | SendGridAPIKey string
11 | SendGridFromEmail string
12 | }
13 |
14 | func initConfig() Config {
15 | return Config{
16 | SendGridAPIKey: getEnvOrPanic("SENDGRID_API_KEY", "SendGrid API KEY is required"),
17 | SendGridFromEmail: getEnvOrPanic("SENDGRID_FROM_EMAIL", "SendGrid From email is required"),
18 | }
19 | }
20 |
21 | func getEnvOrPanic(key, err string) string {
22 | if value, ok := os.LookupEnv(key); ok {
23 | return value
24 | }
25 |
26 | panic(err)
27 | }
28 |
--------------------------------------------------------------------------------
/daily.templ:
--------------------------------------------------------------------------------
1 |
2 |
3 | Insights
4 |
5 |
6 | Hey {{ .User.FirstName }}, here are your daily daily insight(s)
7 |
8 |
9 | {{ range .Insights }}
10 | -
11 |
12 |
15 | "{{ .Text }}"
16 |
17 | {{ .Note }}
18 |
19 |
20 |
- {{ .BookTitle }} - {{ .BookAuthors }}
21 |
22 |
23 | {{ end }}
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/db.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "log"
7 |
8 | "github.com/go-sql-driver/mysql"
9 | )
10 |
11 | type MySQLStorage struct {
12 | db *sql.DB
13 | }
14 |
15 | func NewMySQLStorage(cfg mysql.Config) *MySQLStorage {
16 | db, err := sql.Open("mysql", cfg.FormatDSN())
17 | if err != nil {
18 | log.Fatal(err)
19 | }
20 |
21 | pingErr := db.Ping()
22 | if pingErr != nil {
23 | log.Fatal(pingErr)
24 | }
25 |
26 | fmt.Println("Connected!")
27 |
28 | return &MySQLStorage{db: db}
29 | }
30 |
31 | func (s *MySQLStorage) Init() (*sql.DB, error) {
32 | // initialize the tables
33 | if err := s.createUsersTable(); err != nil {
34 | return nil, err
35 | }
36 |
37 | if err := s.createBooksTable(); err != nil {
38 | return nil, err
39 | }
40 |
41 | if err := s.createHighlightsTable(); err != nil {
42 | return nil, err
43 | }
44 |
45 | return s.db, nil
46 | }
47 |
48 | func (s *MySQLStorage) createUsersTable() error {
49 | _, err := s.db.Exec(`
50 | CREATE TABLE IF NOT EXISTS users (
51 | id INT UNSIGNED NOT NULL AUTO_INCREMENT,
52 | email VARCHAR(255) NOT NULL,
53 | firstName VARCHAR(255) NOT NULL,
54 | lastName VARCHAR(255) NOT NULL,
55 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
56 |
57 | PRIMARY KEY (id),
58 | UNIQUE KEY (email)
59 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
60 | `)
61 |
62 | return err
63 |
64 | }
65 |
66 | func (s *MySQLStorage) createBooksTable() error {
67 | _, err := s.db.Exec(`
68 | CREATE TABLE IF NOT EXISTS books (
69 | isbn VARCHAR(255) NOT NULL,
70 | title VARCHAR(255) NOT NULL,
71 | authors VARCHAR(255) NOT NULL,
72 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
73 |
74 | PRIMARY KEY (isbn),
75 | UNIQUE KEY (isbn)
76 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
77 | `)
78 |
79 | return err
80 | }
81 |
82 | func (s *MySQLStorage) createHighlightsTable() error {
83 | _, err := s.db.Exec(`
84 | CREATE TABLE IF NOT EXISTS highlights (
85 | id INT NOT NULL AUTO_INCREMENT,
86 | text TEXT,
87 | location VARCHAR(255) NOT NULL,
88 | note TEXT,
89 | userId INT UNSIGNED NOT NULL,
90 | bookId VARCHAR(255) NOT NULL,
91 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
92 |
93 | PRIMARY KEY (id),
94 | FOREIGN KEY (userId) REFERENCES users(id),
95 | FOREIGN KEY (bookId) REFERENCES books(isbn)
96 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
97 | `)
98 |
99 | return err
100 | }
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/sikozonpc/notebase
2 |
3 | go 1.21.5
4 |
5 | require (
6 | github.com/go-sql-driver/mysql v1.7.1
7 | github.com/gorilla/mux v1.8.1
8 | github.com/sendgrid/sendgrid-go v3.14.0+incompatible
9 | )
10 |
11 | require github.com/sendgrid/rest v2.6.9+incompatible // indirect
12 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
2 | github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
3 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
4 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
5 | github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
6 | github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
7 | github.com/sendgrid/sendgrid-go v3.14.0+incompatible h1:KDSasSTktAqMJCYClHVE94Fcif2i7P7wzISv1sU6DUA=
8 | github.com/sendgrid/sendgrid-go v3.14.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
9 |
--------------------------------------------------------------------------------
/mailer.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | type Mailer interface {
4 | SendInsights(ins []*DailyInsight, u *User) error
5 | }
6 |
7 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/go-sql-driver/mysql"
7 | )
8 |
9 | func main() {
10 | cfg := mysql.Config{
11 | User: "root",
12 | Passwd: "mypassword",
13 | Net: "tcp",
14 | Addr: "127.0.0.1:3306",
15 | DBName: "highlights",
16 | AllowNativePasswords: true,
17 | ParseTime: true,
18 | }
19 |
20 | storage := NewMySQLStorage(cfg)
21 |
22 | db, err := storage.Init()
23 | if err != nil {
24 | log.Fatal(err)
25 | }
26 |
27 | apiServer := NewAPIServer(":3000", db)
28 | apiServer.Run()
29 | }
30 |
--------------------------------------------------------------------------------
/sendgrid.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "html/template"
7 |
8 | "github.com/sendgrid/sendgrid-go"
9 | "github.com/sendgrid/sendgrid-go/helpers/mail"
10 | )
11 |
12 | var FromName = "Notebase"
13 |
14 | type SendGridMailer struct {
15 | FromEmail string
16 | Client *sendgrid.Client
17 | }
18 |
19 | func NewSendGridMailer(apiKey, fromEmail string) *SendGridMailer {
20 | client := sendgrid.NewSendClient(apiKey)
21 |
22 | return &SendGridMailer{
23 | FromEmail: fromEmail,
24 | Client: client,
25 | }
26 | }
27 |
28 | func (m *SendGridMailer) SendInsights(insights []*DailyInsight, u *User) error {
29 | if u.Email == "" {
30 | return fmt.Errorf("user has no email")
31 | }
32 |
33 | from := mail.NewEmail(FromName, m.FromEmail)
34 | subject := "Daily Insight(s)"
35 | userName := fmt.Sprintf("%v %v", u.FirstName, u.LastName)
36 |
37 | to := mail.NewEmail(userName, u.Email)
38 |
39 | html := BuildInsightsMailTemplate(u, insights)
40 |
41 | message := mail.NewSingleEmail(from, subject, to, "", html)
42 | _, err := m.Client.Send(message)
43 | if err != nil {
44 | return err
45 | }
46 |
47 | return nil
48 | }
49 |
50 | func BuildInsightsMailTemplate(u *User, ins []*DailyInsight) string {
51 | templ, err := template.ParseFiles("daily.templ")
52 | if err != nil {
53 | panic(err)
54 | }
55 |
56 | payload := struct {
57 | User *User
58 | Insights []*DailyInsight
59 | }{
60 | User: u,
61 | Insights: ins,
62 | }
63 |
64 | var out bytes.Buffer
65 | err = templ.Execute(&out, payload)
66 | if err != nil {
67 | panic(err)
68 | }
69 |
70 | return out.String()
71 | }
72 |
--------------------------------------------------------------------------------
/service.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "mime/multipart"
8 | "net/http"
9 | "strconv"
10 |
11 | "github.com/gorilla/mux"
12 | )
13 |
14 | type Service struct {
15 | store Storage
16 | mailer Mailer
17 | }
18 |
19 | func NewService(store Storage, mailer Mailer) *Service {
20 | return &Service{store: store, mailer: mailer}
21 | }
22 |
23 | func (s *Service) RegisterRoutes(router *mux.Router) {
24 | router.HandleFunc("/users/{userID}/parse-kindle-file", s.handleParseKindleFile).Methods("POST")
25 | router.HandleFunc("/cloud/send-daily-insights", s.handleSendDailyInsights).Methods("GET")
26 | }
27 |
28 | func (s *Service) handleParseKindleFile(w http.ResponseWriter, r *http.Request) {
29 | vars := mux.Vars(r)
30 | userID := vars["userID"]
31 |
32 | file, _, err := r.FormFile("file")
33 | if err != nil {
34 | WriteJSON(w, http.StatusBadRequest, fmt.Sprintf("Error parsing file: %v", err))
35 | return
36 | }
37 |
38 | defer file.Close()
39 |
40 | // parse that multipart file
41 | raw, err := parseKindleExtractFile(file)
42 | if err != nil {
43 | WriteJSON(w, http.StatusBadRequest, fmt.Sprintf("Error parsing file: %v", err))
44 | return
45 | }
46 |
47 | userIDint, _ := strconv.Atoi(userID)
48 | if err := s.createDataFromRawBook(raw, userIDint); err != nil {
49 | WriteJSON(w, http.StatusInternalServerError, fmt.Sprintf("Error creating data from raw book: %v", err))
50 | return
51 | }
52 |
53 | WriteJSON(w, http.StatusOK, "Successfully parsed file")
54 | }
55 |
56 | func (s *Service) handleSendDailyInsights(w http.ResponseWriter, r *http.Request) {
57 | // get users
58 | users, err := s.store.GetUsers()
59 | if err != nil {
60 | WriteJSON(w, http.StatusInternalServerError, err.Error())
61 | return
62 | }
63 |
64 | // loop over users and get random highlights
65 | for _, u := range users {
66 | hs, err := s.store.GetRandomHighlights(3, u.ID)
67 | if err != nil {
68 | WriteJSON(w, http.StatusInternalServerError, err.Error())
69 | return
70 | }
71 |
72 | if len(hs) == 0 {
73 | continue
74 | }
75 |
76 | insights, err := s.buildInsights(hs)
77 | if err != nil {
78 | WriteJSON(w, http.StatusInternalServerError, err.Error())
79 | return
80 | }
81 |
82 | err = s.mailer.SendInsights(insights, u)
83 | if err != nil {
84 | WriteJSON(w, http.StatusInternalServerError, err.Error())
85 | return
86 | }
87 | }
88 |
89 | WriteJSON(w, http.StatusOK, nil)
90 | }
91 |
92 | func parseKindleExtractFile(file multipart.File) (*RawExtractBook, error) {
93 | decoder := json.NewDecoder(file)
94 |
95 | raw := new(RawExtractBook)
96 | if err := decoder.Decode(raw); err != nil {
97 | return nil, err
98 | }
99 |
100 | return raw, nil
101 | }
102 |
103 | func (s *Service) createDataFromRawBook(raw *RawExtractBook, userID int) error {
104 | _, err := s.store.GetBookByISBN(raw.ASIN)
105 | if err != nil {
106 | s.store.CreateBook(Book{
107 | ISBN: raw.ASIN,
108 | Title: raw.Title,
109 | Authors: raw.Authors,
110 | })
111 | }
112 |
113 | // create highlights
114 | hs := make([]Highlight, len(raw.Highlights))
115 | for i, h := range raw.Highlights {
116 | hs[i] = Highlight{
117 | Text: h.Text,
118 | Location: h.Location.URL,
119 | Note: h.Note,
120 | UserID: userID,
121 | BookID: raw.ASIN,
122 | }
123 | }
124 |
125 | err = s.store.CreateHighlights(hs)
126 | if err != nil {
127 | log.Println("Error creating highlights: ", err)
128 | return err
129 | }
130 |
131 | return nil
132 | }
133 |
134 | func (s *Service) buildInsights(hs []*Highlight) ([]*DailyInsight, error) {
135 | var insights []*DailyInsight
136 |
137 | for _, h := range hs {
138 | book, err := s.store.GetBookByISBN(h.BookID)
139 | if err != nil {
140 | return nil, err
141 | }
142 |
143 | insights = append(insights, &DailyInsight{
144 | Text: h.Text,
145 | Note: h.Note,
146 | BookAuthors: book.Authors,
147 | BookTitle: book.Title,
148 | })
149 | }
150 |
151 | return insights, nil
152 | }
153 |
--------------------------------------------------------------------------------
/store.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | )
7 |
8 | type Store struct {
9 | db *sql.DB
10 | }
11 |
12 | type Storage interface {
13 | CreateBook(Book) error
14 | CreateHighlights([]Highlight) error
15 | GetBookByISBN(string) (*Book, error)
16 | GetRandomHighlights(limit, userId int) ([]*Highlight, error)
17 | GetUsers() ([]*User, error)
18 | }
19 |
20 | func NewStore(db *sql.DB) *Store {
21 | return &Store{db: db}
22 | }
23 |
24 | func (s *Store) CreateBook(b Book) error {
25 | _, err := s.db.Exec(`
26 | INSERT INTO books (isbn, title, authors)
27 | VALUES (?, ?, ?)
28 | `, b.ISBN, b.Title, b.Authors)
29 |
30 | if err != nil {
31 | return err
32 | }
33 |
34 | return nil
35 | }
36 |
37 | func (s *Store) CreateHighlights(hs []Highlight) error {
38 | values := []interface{}{}
39 |
40 | query := "INSERT INTO highlights (text, location, note, userId, bookId) VALUES "
41 | for _, h := range hs {
42 | query += "(?, ?, ?, ?, ?),"
43 | values = append(values, h.Text, h.Location, h.Note, h.UserID, h.BookID)
44 | }
45 |
46 | query = query[:len(query)-1]
47 |
48 | _, err := s.db.Exec(query, values...)
49 | if err != nil {
50 | return err
51 | }
52 |
53 | return nil
54 | }
55 |
56 | func (s *Store) GetBookByISBN(isbn string) (*Book, error) {
57 | rows, err := s.db.Query(`
58 | SELECT * FROM books WHERE isbn = ?
59 | `, isbn)
60 | if err != nil {
61 | return nil, err
62 | }
63 |
64 | book := new(Book)
65 | for rows.Next() {
66 | if err := rows.Scan(&book.ISBN, &book.Title, &book.Authors, &book.CreatedAt); err != nil {
67 | return nil, err
68 | }
69 | }
70 |
71 | if book.ISBN == "" {
72 | return nil, fmt.Errorf("book not found")
73 | }
74 |
75 | return book, nil
76 | }
77 |
78 | func (s *Store) GetUsers() ([]*User, error) {
79 | rows, err := s.db.Query("SELECT * FROM users")
80 | if err != nil {
81 | return nil, err
82 | }
83 |
84 | users := make([]*User, 0)
85 | for rows.Next() {
86 | u := new(User)
87 |
88 | if err := rows.Scan(
89 | &u.ID,
90 | &u.Email,
91 | &u.FirstName,
92 | &u.LastName,
93 | &u.CreatedAt,
94 | ); err != nil {
95 | return nil, err
96 | }
97 |
98 | users = append(users, u)
99 | }
100 |
101 | return users, nil
102 | }
103 |
104 | func (s *Store) GetRandomHighlights(n, userID int) ([]*Highlight, error) {
105 | rows, err := s.db.Query("SELECT * FROM highlights WHERE userId = ? ORDER BY RAND() LIMIT ?", userID, n)
106 | if err != nil {
107 | return nil, err
108 | }
109 |
110 | var highlights []*Highlight
111 | for rows.Next() {
112 | h := new(Highlight)
113 |
114 | if err := rows.Scan(
115 | &h.ID,
116 | &h.Text,
117 | &h.Location,
118 | &h.Note,
119 | &h.UserID,
120 | &h.BookID,
121 | &h.CreatedAt,
122 | ); err != nil {
123 | return nil, err
124 | }
125 |
126 | highlights = append(highlights, h)
127 | }
128 |
129 | return highlights, nil
130 | }
131 |
--------------------------------------------------------------------------------
/types.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "time"
4 |
5 | type RawExtractBook struct {
6 | ASIN string `json:"asin"`
7 | Title string `json:"title"`
8 | Authors string `json:"authors"`
9 | Highlights []RawExtractHighlight `json:"highlights"`
10 | }
11 |
12 | type RawExtractHighlight struct {
13 | Text string `json:"text"`
14 | Location struct {
15 | Value int `json:"value"`
16 | URL string `json:"url"`
17 | } `json:"location"`
18 | IsNoteOnly bool `json:"isNoteOnly"`
19 | Note string `json:"note"`
20 | }
21 |
22 | type Book struct {
23 | ISBN string `json:"isbn"`
24 | Title string `json:"title"`
25 | Authors string `json:"authors"`
26 | CreatedAt time.Time `json:"created_at"`
27 | }
28 |
29 | type Highlight struct {
30 | ID int `json:"id"`
31 | Text string `json:"text"`
32 | Location string `json:"location"`
33 | Note string `json:"note"`
34 | UserID int `json:"userId"`
35 | BookID string `json:"bookId"`
36 | CreatedAt time.Time `json:"created_at"`
37 | }
38 |
39 | type User struct {
40 | ID int `json:"id"`
41 | Email string `json:"email"`
42 | FirstName string `json:"firstName"`
43 | LastName string `json:"lastName"`
44 | CreatedAt time.Time `json:"created_at"`
45 | }
46 |
47 | type DailyInsight struct {
48 | Text string
49 | Note string
50 | BookAuthors string
51 | BookTitle string
52 | }
53 |
--------------------------------------------------------------------------------
/utils.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | )
7 |
8 |
9 | func WriteJSON(w http.ResponseWriter, status int, v any) {
10 | w.Header().Set("Content-Type", "application/json")
11 | w.WriteHeader(status)
12 | json.NewEncoder(w).Encode(v)
13 | }
--------------------------------------------------------------------------------