Skip to content

Commit

Permalink
feat: add habitica and todoist webhook handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
haclark30 committed Jun 5, 2024
1 parent f7903ac commit d255c33
Show file tree
Hide file tree
Showing 7 changed files with 317 additions and 2 deletions.
109 changes: 109 additions & 0 deletions internal/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"log"
"misc/internal/models"
"os"
"strconv"
"time"
Expand All @@ -22,6 +23,12 @@ type Service interface {
// Close terminates the database connection.
// It returns an error if the connection cannot be closed.
Close() error

Init() error

GetHabitRule(string) (*models.HabiticaHabitRule, error)
GetTodoistHabiticaTextRules() ([]models.TodoistHabiticaTextRule, error)
GetTodoistHabiticaProjectRule(string) (models.TodoistHabiticaProjectRule, error)
}

type service struct {
Expand Down Expand Up @@ -49,6 +56,10 @@ func New() Service {
dbInstance = &service{
db: db,
}

if err := dbInstance.Init(); err != nil {
log.Fatal(err)
}
return dbInstance
}

Expand Down Expand Up @@ -111,3 +122,101 @@ func (s *service) Close() error {
log.Printf("Disconnected from database: %s", dburl)
return s.db.Close()
}

// Create initial tables in the database
func (s *service) Init() error {
_, err := s.db.Exec(
`CREATE TABLE IF NOT EXISTS HabiticaHabitRule (
id INTEGER PRIMARY KEY,
name TEXT,
habitId TEXT,
dailyId TEXT,
minScore INTEGER
)`,
)
if err != nil {
return fmt.Errorf("error initializing database: %w", err)
}

_, err = s.db.Exec(
`CREATE TABLE IF NOT EXISTS TodoistHabitTextRule (
id INTEGER PRIMARY KEY,
name TEXT,
rule TEXT,
habitId TEXT
)`,
)
if err != nil {
return fmt.Errorf("error initializing database: %w", err)
}

_, err = s.db.Exec(
`CREATE TABLE IF NOT EXISTS TodoistHabitProjectRule (
id INTEGER PRIMARY KEY,
name TEXT,
todoistProjectId TEXT,
habitId TEXT
)`,
)

if err != nil {
return fmt.Errorf("error initializing database: %w", err)
}

_, err = s.db.Exec(
`CREATE TABLE IF NOT EXISTS TodoistHabitTextRule (
id INTEGER PRIMARY KEY,
name TEXT,
ruleText TEXT,
habitId TEXT
)`,
)

if err != nil {
return fmt.Errorf("error initializing database: %w", err)
}
return nil
}

func (s *service) GetHabitRule(habitId string) (*models.HabiticaHabitRule, error) {
var rule models.HabiticaHabitRule
row := s.db.QueryRow(
`SELECT habitId, dailyId, minScore FROM HabiticaHabitRule WHERE habitId = ?`,
habitId,
)
if err := row.Scan(&rule.HabitId, &rule.DailyId, &rule.MinScore); err != nil {
return nil, fmt.Errorf("error retrieving habit rule: %w", err)
}
return &rule, nil
}

func (s *service) GetTodoistHabiticaTextRules() ([]models.TodoistHabiticaTextRule, error) {
rules := make([]models.TodoistHabiticaTextRule, 0)
rows, err := s.db.Query(`SELECT name, rule, habitId FROM TodoistHabitTextRule;`)
if err != nil {
return rules, fmt.Errorf("error creating text rule query: %w", err)
}
defer rows.Close()

for rows.Next() {
var rule models.TodoistHabiticaTextRule
err := rows.Scan(&rule.Name, &rule.Rule, &rule.HabitId)
if err != nil {
return rules, fmt.Errorf("error scanning text rule row: %w", err)
}
rules = append(rules, rule)
}
return rules, nil
}

func (s *service) GetTodoistHabiticaProjectRule(projectId string) (models.TodoistHabiticaProjectRule, error) {
var rule models.TodoistHabiticaProjectRule
row := s.db.QueryRow(
`SELECT name, todoistProjectId, habitId FROM TodoistHabitProjectRule WHERE todoistProjectId = ?`,
projectId,
)
if err := row.Scan(&rule.Name, &rule.ProjectId, &rule.HabitId); err != nil {
return rule, fmt.Errorf("error retrieving todoist project rule: %w", err)
}
return rule, nil
}
27 changes: 27 additions & 0 deletions internal/models/habitica.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package models

const HabiticaHabitType = "habit"
const HabiticaDailyType = "daily"
const HabiticaTodoType = "todo"

type HabiticaWebhook struct {
Type string `json:"type"`
Direction string `json:"direction"`
Task HabiticaWebhookTask `json:"task"`
}

type HabiticaWebhookTask struct {
Id string `json:"id"`
Up int `json:"counterUp"`
Down int `json:"counterDown"`

Type string `json:"type"`
Text string `json:"text"`
Notes string `json:"notes"`
}

type HabiticaHabitRule struct {
HabitId string
DailyId string
MinScore int
}
25 changes: 25 additions & 0 deletions internal/models/todoist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package models

type TodoistWebhook struct {
EventName string `json:"event_name"`
EventData TodoistEvent `json:"event_data"`
}

type TodoistEvent struct {
Content string `json:"content"`
Description string `json:"description"`
ProjectId string `json:"project_id"`
Id string `json:"id"`
}

type TodoistHabiticaTextRule struct {
Name string
Rule string
HabitId string
}

type TodoistHabiticaProjectRule struct {
Name string
ProjectId string
HabitId string
}
46 changes: 45 additions & 1 deletion internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package server

import (
"encoding/json"
"io"
"log"
"log/slog"
"net/http"

"github.com/a-h/templ"
"misc/cmd/web"
"misc/internal/models"

"github.com/a-h/templ"
)

func (s *Server) RegisterRoutes() http.Handler {
Expand All @@ -20,6 +24,8 @@ func (s *Server) RegisterRoutes() http.Handler {
mux.Handle("/assets/", fileServer)
mux.Handle("/web", templ.Handler(web.HelloForm()))
mux.HandleFunc("/hello", web.HelloWebHandler)
mux.HandleFunc("POST /habiticaEvent", s.HabiticaWebhookHandler)
mux.HandleFunc("POST /todoistEvent", s.TodoistWebhookHandler)

return mux
}
Expand All @@ -28,6 +34,8 @@ func (s *Server) HelloWorldHandler(w http.ResponseWriter, r *http.Request) {
resp := make(map[string]string)
resp["message"] = "Hello World"

req, _ := io.ReadAll(r.Body)
slog.Info("got req", "method", r.Method, "body", string(req))
jsonResp, err := json.Marshal(resp)
if err != nil {
log.Fatalf("error handling JSON marshal. Err: %v", err)
Expand All @@ -36,6 +44,42 @@ func (s *Server) HelloWorldHandler(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(jsonResp)
}

func (s *Server) HabiticaWebhookHandler(w http.ResponseWriter, r *http.Request) {
var req models.HabiticaWebhook
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
slog.Error("error decoding request", "err", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Task.Type != models.HabiticaHabitType {
slog.Info("got non habit task", "event", req)
w.WriteHeader(http.StatusOK)
return
}

err = s.habService.CheckMinHabit(req.Task.Id, req.Task.Up)
if err != nil {
slog.Error("error checking habit", "err", err)
}
}

func (s *Server) TodoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
var req models.TodoistWebhook
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
slog.Error("error decoding request", "err", err)
return
}
slog.Info("got todoist event", "event", req)

err = s.todoHabService.ScoreTask(req.EventData.Content, req.EventData.ProjectId)

if err != nil {
slog.Error("error scoring task", "err", err)
return
}
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
jsonResp, err := json.Marshal(s.db.Health())

Expand Down
20 changes: 19 additions & 1 deletion internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ import (

_ "github.com/joho/godotenv/autoload"

"misc/clients"

Check failure on line 12 in internal/server/server.go

View workflow job for this annotation

GitHub Actions / build

package misc/clients is not in std (/home/runner/go/pkg/mod/golang.org/[email protected]/src/misc/clients)
"misc/internal/database"
"misc/internal/services"
)

type Server struct {
port int

db database.Service
db database.Service
habService services.HabiticaMinHabitService
todoHabService services.TodoistHabiticaService
}

func NewServer() *http.Server {
Expand All @@ -25,6 +29,20 @@ func NewServer() *http.Server {

db: database.New(),
}
habClient := clients.NewHabiticaClient(
os.Getenv("HABITICA_API_USER"),
os.Getenv("HABITICA_API_KEY"),
)

NewServer.habService = *services.NewHabitcaMinHabitService(
NewServer.db,
&habClient,
)

NewServer.todoHabService = services.NewTodoistHabiticaService(
NewServer.db,
&habClient,
)

// Declare Server config
server := &http.Server{
Expand Down
37 changes: 37 additions & 0 deletions internal/services/habiticaMinHabits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package services

import (
"fmt"
"misc/internal/models"
)

type HabitRuleStore interface {
GetHabitRule(string) (*models.HabiticaHabitRule, error)
}

type DailyUpdater interface {
ScoreDaily(string) error
}

type HabiticaMinHabitService struct {
db HabitRuleStore
updater DailyUpdater
}

func NewHabitcaMinHabitService(db HabitRuleStore, updater DailyUpdater) *HabiticaMinHabitService {
return &HabiticaMinHabitService{db: db, updater: updater}
}

func (h *HabiticaMinHabitService) CheckMinHabit(habitId string, currScore int) error {
rule, err := h.db.GetHabitRule(habitId)
if err != nil {
return fmt.Errorf("error getting habit rule: %w", err)
}

if currScore == rule.MinScore {
if err := h.updater.ScoreDaily(rule.DailyId); err != nil {
return fmt.Errorf("error scoring daily: %w", err)
}
}
return nil
}
55 changes: 55 additions & 0 deletions internal/services/todoistToHabitica.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package services

import (
"fmt"
"log/slog"
"misc/internal/models"
"strings"
)

type TodoistHabiticaRuleStore interface {
GetTodoistHabiticaTextRules() ([]models.TodoistHabiticaTextRule, error)
GetTodoistHabiticaProjectRule(string) (models.TodoistHabiticaProjectRule, error)
}

type TodoistHabiticaService struct {
db TodoistHabiticaRuleStore
updater DailyUpdater
}

func NewTodoistHabiticaService(db TodoistHabiticaRuleStore, updater DailyUpdater) TodoistHabiticaService {
return TodoistHabiticaService{
db: db,
updater: updater,
}
}

func (s *TodoistHabiticaService) ScoreTask(taskStr, projectId string) error {
// check text rules first, if we hit one score the task and return
rules, err := s.db.GetTodoistHabiticaTextRules()

if err != nil {
return fmt.Errorf("error getting text rules: %w", err)
}

slog.Info("got text rules", "rules", rules, "taskStr", taskStr)
for _, rule := range rules {
if strings.HasPrefix(strings.ToLower(taskStr), rule.Rule) {
if err := s.updater.ScoreDaily(rule.HabitId); err != nil {
return fmt.Errorf("error scoring habit: %w", err)
}
return nil
}
}

// check project rule
rule, err := s.db.GetTodoistHabiticaProjectRule(projectId)
if err != nil {
return fmt.Errorf("error getting project rule: %w", err)
}
err = s.updater.ScoreDaily(rule.HabitId)
if err != nil {
return fmt.Errorf("error scoring habit: %w", err)
}
return nil
}

0 comments on commit d255c33

Please sign in to comment.