================================================================
Repopack Output File
================================================================

This file was generated by Repopack on: 2024-08-12T00:02:56.842Z

Purpose:
--------
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.

File Format:
------------
The content is organized as follows:
1. This header section
2. Repository structure
3. Multiple file entries, each consisting of:
  a. A separator line (================)
  b. The file path (File: path/to/file)
  c. Another separator line
  d. The full contents of the file
  e. A blank line

Usage Guidelines:
-----------------
1. This file should be treated as read-only. Any changes should be made to the
  original repository files, not this packed version.
2. When processing this file, use the separators and "File:" markers to
  distinguish between different files in the repository.
3. Be aware that this file may contain sensitive information. Handle it with
  the same level of security as you would the original repository.

Notes:
------
- Some files may have been excluded based on .gitignore rules and Repopack's
  configuration.
- Binary files are not included in this packed representation. Please refer to
  the Repository Structure section for a complete list of file paths, including
  binary files.



For more information about Repopack, visit: https://github.com/yamadashy/repopack

================================================================
Repository Structure
================================================================
handlers/
  son_handlers.go
  webhook_handler.go
middleware/
  auth.go
models/
  son.go
services/
  database.go
  listmonk_client_test.go
  listmonk_client.go
  son_executor.go
  son_storage_test.go
  son_storage.go
utils/
  config.go
  errors.go
  logger.go
  pretty_print.go
  uuid.go
.env.local
docker-compose.yml
Dockerfile
go.mod
main.go

================================================================
Repository Files
================================================================

================
File: handlers/son_handlers.go
================
package handlers

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/troneras/ghost-listmonk-connector/models"
	"github.com/troneras/ghost-listmonk-connector/services"
	"github.com/troneras/ghost-listmonk-connector/utils"
)

type SonHandler struct {
	storage *services.SonStorage
}

type ListmonkHandler struct {
	client *services.ListmonkClient
}

func NewSonHandler(storage *services.SonStorage) *SonHandler {
	return &SonHandler{storage: storage}
}

func NewListmonkHandler(client *services.ListmonkClient) *ListmonkHandler {
	return &ListmonkHandler{client: client}
}

func (h *ListmonkHandler) GetLists(c *gin.Context) {
	lists, err := h.client.GetLists()
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to get lists: %v", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"data": lists})
}

func (h *ListmonkHandler) GetTemplates(c *gin.Context) {
	templates, err := h.client.GetTemplates()
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to get templates: %v", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"data": templates})
}

func (h *SonHandler) Create(c *gin.Context) {
	var son models.Son
	if err := c.ShouldBindJSON(&son); err != nil {
		utils.ErrorLogger.Errorf("Invalid Son data: %v", err)
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if err := h.storage.Create(&son); err != nil {
		utils.ErrorLogger.Errorf("Failed to create Son: %v", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	utils.InfoLogger.Infof("Created Son: %s", utils.PrettyPrint(son))
	c.JSON(http.StatusCreated, son)
}

func (h *SonHandler) Get(c *gin.Context) {
	id := c.Param("id")
	son, err := h.storage.Get(id)
	if err != nil {
		if err == services.ErrSonNotFound {
			utils.ErrorLogger.Errorf("Son not found: %s", id)
			c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"})
		} else {
			utils.ErrorLogger.Errorf("Failed to get Son: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		}
		return
	}

	utils.InfoLogger.Infof("Retrieved Son: %s", utils.PrettyPrint(son))
	c.JSON(http.StatusOK, son)
}

func (h *SonHandler) Update(c *gin.Context) {
	id := c.Param("id")
	var son models.Son
	if err := c.ShouldBindJSON(&son); err != nil {
		utils.ErrorLogger.Errorf("Invalid Son data: %v", err)
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	son.ID = id
	if err := h.storage.Update(son); err != nil {
		if err == services.ErrSonNotFound {
			utils.ErrorLogger.Errorf("Son not found for update: %s", id)
			c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"})
		} else {
			utils.ErrorLogger.Errorf("Failed to update Son: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		}
		return
	}

	utils.InfoLogger.Infof("Updated Son: %s", utils.PrettyPrint(son))
	c.JSON(http.StatusOK, son)
}

func (h *SonHandler) Delete(c *gin.Context) {
	id := c.Param("id")
	if err := h.storage.Delete(id); err != nil {
		if err == services.ErrSonNotFound {
			utils.ErrorLogger.Errorf("Son not found for deletion: %s", id)
			c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"})
		} else {
			utils.ErrorLogger.Errorf("Failed to delete Son: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		}
		return
	}

	utils.InfoLogger.Infof("Deleted Son: %s", id)
	c.JSON(http.StatusOK, gin.H{"message": "Son deleted successfully"})
}

func (h *SonHandler) List(c *gin.Context) {
	sons, err := h.storage.List()

	if err != nil {
		utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	utils.InfoLogger.Infof("Retrieved %d Sons", len(sons))
	c.JSON(http.StatusOK, sons)
}

================
File: handlers/webhook_handler.go
================
package handlers

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/troneras/ghost-listmonk-connector/models"
	"github.com/troneras/ghost-listmonk-connector/services"
	"github.com/troneras/ghost-listmonk-connector/utils"
)

type WebhookHandler struct {
	sonStorage *services.SonStorage
	executor   *services.SonExecutor
}

func NewWebhookHandler(sonStorage *services.SonStorage, executor *services.SonExecutor) *WebhookHandler {
	return &WebhookHandler{
		sonStorage: sonStorage,
		executor:   executor,
	}
}

func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
	var webhookData map[string]interface{}
	if err := c.ShouldBindJSON(&webhookData); err != nil {
		utils.ErrorLogger.Errorf("Invalid webhook data: %v", err)
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook data"})
		return
	}

	utils.InfoLogger.Infof("Received webhook: %s", utils.PrettyPrint(webhookData))

	triggerType, err := determineTriggerType(webhookData)
	if err != nil {
		utils.ErrorLogger.Errorf("Unable to determine trigger type: %v", err)
		c.JSON(http.StatusBadRequest, gin.H{"error": "Unable to determine trigger type"})
		return
	}

	utils.InfoLogger.Infof("Determined trigger type: %s", triggerType)

	// Find and execute relevant Sons
	sons, err := h.sonStorage.List()

	if err != nil {
		utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list Sons"})
		return
	}

	executedCount := 0
	for _, son := range sons {
		if son.Trigger == triggerType {
			go func(s models.Son) {
				utils.InfoLogger.Infof("Executing Son %s for trigger %s", s.ID, triggerType)
				h.executor.ExecuteSon(s, webhookData)
			}(son)
			executedCount++
		}
	}

	utils.InfoLogger.Infof("Executed %d Sons for trigger %s", executedCount, triggerType)
	c.JSON(http.StatusOK, gin.H{"message": "Webhook processed successfully", "sons_executed": executedCount})
}

func determineTriggerType(webhookData map[string]interface{}) (models.TriggerType, error) {
	if member, ok := webhookData["member"].(map[string]interface{}); ok {
		if _, ok := member["current"]; ok {
			if _, ok := member["previous"]; ok {
				return models.TriggerMemberUpdated, nil
			}
			return models.TriggerMemberCreated, nil
		}
		// Assuming member deletion doesn't have 'current' field
		return models.TriggerMemberDeleted, nil
	}

	if post, ok := webhookData["post"].(map[string]interface{}); ok {
		if status, ok := post["status"].(string); ok && status == "published" {
			return models.TriggerPostPublished, nil
		}
		if status, ok := post["status"].(string); ok && status == "scheduled" {
			return models.TriggerPostScheduled, nil
		}
	}

	if page, ok := webhookData["page"].(map[string]interface{}); ok {
		if status, ok := page["status"].(string); ok && status == "published" {
			return models.TriggerPagePublished, nil
		}
	}

	return "", utils.NewError("UnknownTriggerType", "Unable to determine trigger type from webhook data")
}

================
File: middleware/auth.go
================
package middleware

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func APIKeyAuth(validAPIKey string) gin.HandlerFunc {
	return func(c *gin.Context) {
		apiKey := c.GetHeader("Authorization")
		if apiKey == "" {
			apiKey = c.Query("api_key")
		}

		if apiKey != "Bearer "+validAPIKey {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
			c.Abort()
			return
		}

		c.Next()
	}
}

================
File: models/son.go
================
package models

import (
	"encoding/json"
	"errors"
	"time"
)

type TriggerType string
type ActionType string

const (
	TriggerMemberCreated TriggerType = "member_created"
	TriggerMemberDeleted TriggerType = "member_deleted"
	TriggerMemberUpdated TriggerType = "member_updated"
	TriggerPagePublished TriggerType = "page_published"
	TriggerPostPublished TriggerType = "post_published"
	TriggerPostScheduled TriggerType = "post_scheduled"
)

const (
	ActionSendTransactionalEmail ActionType = "send_transactional_email"
	ActionManageSubscriber       ActionType = "manage_subscriber"
	ActionCreateCampaign         ActionType = "create_campaign"
)

type Son struct {
	ID        string      `json:"id"`
	Name      string      `json:"name"`
	Trigger   TriggerType `json:"trigger"`
	Delay     Duration    `json:"delay"`
	Actions   []Action    `json:"actions"`
	CreatedAt time.Time   `json:"created_at"`
	UpdatedAt time.Time   `json:"updated_at"`
}

type Action struct {
	Type       ActionType     `json:"type"`
	Parameters map[string]any `json:"parameters"`
}

// Duration is a custom type to handle time.Duration in JSON
type Duration time.Duration

func (d Duration) MarshalJSON() ([]byte, error) {
	return json.Marshal(time.Duration(d).Minutes())
}

func (d *Duration) UnmarshalJSON(b []byte) error {
	var v interface{}
	if err := json.Unmarshal(b, &v); err != nil {
		return err
	}
	switch value := v.(type) {
	case float64:
		*d = Duration(time.Duration(value) * time.Minute)
		return nil
	case string:
		tmp, err := time.ParseDuration(value)
		if err != nil {
			return err
		}
		*d = Duration(tmp)
		return nil
	default:
		return errors.New("invalid duration")
	}
}

================
File: services/database.go
================
package services

import (
	"database/sql"
	"encoding/json"
	"time"

	_ "github.com/mattn/go-sqlite3"
	"github.com/troneras/ghost-listmonk-connector/models"
	"github.com/troneras/ghost-listmonk-connector/utils"
)

type Database struct {
	db *sql.DB
}

func NewDatabase(dbPath string) (*Database, error) {
	db, err := sql.Open("sqlite3", dbPath)
	if err != nil {
		return nil, err
	}

	if err := db.Ping(); err != nil {
		return nil, err
	}

	database := &Database{db: db}
	if err := database.createTables(); err != nil {
		return nil, err
	}

	return database, nil
}

func (d *Database) createTables() error {
	_, err := d.db.Exec(`
		CREATE TABLE IF NOT EXISTS sons (
			id TEXT PRIMARY KEY,
			name TEXT,
			trigger TEXT,
			delay INTEGER,
			created_at DATETIME,
			updated_at DATETIME
		);
		CREATE TABLE IF NOT EXISTS actions (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			son_id TEXT,
			type TEXT,
			parameters TEXT,
			FOREIGN KEY (son_id) REFERENCES sons(id)
		);
	`)
	return err
}

func (d *Database) CreateSon(son *models.Son) error {
	son.ID = utils.GenerateUUID()
	son.CreatedAt = time.Now()
	son.UpdatedAt = time.Now()

	tx, err := d.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	_, err = tx.Exec(
		"INSERT INTO sons (id, name, trigger, delay, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
		son.ID, son.Name, son.Trigger, int64(son.Delay), son.CreatedAt, son.UpdatedAt,
	)
	if err != nil {
		return err
	}

	for _, action := range son.Actions {
		parametersJSON, err := json.Marshal(action.Parameters)
		if err != nil {
			return err
		}
		_, err = tx.Exec(
			"INSERT INTO actions (son_id, type, parameters) VALUES (?, ?, ?)",
			son.ID, action.Type, string(parametersJSON),
		)
		if err != nil {
			return err
		}
	}

	return tx.Commit()
}

func (d *Database) GetSon(id string) (*models.Son, error) {
	son := &models.Son{}
	err := d.db.QueryRow(
		"SELECT id, name, trigger, delay, created_at, updated_at FROM sons WHERE id = ?",
		id,
	).Scan(&son.ID, &son.Name, &son.Trigger, &son.Delay, &son.CreatedAt, &son.UpdatedAt)
	if err != nil {
		if err == sql.ErrNoRows {
			return nil, ErrSonNotFound
		}
		return nil, err
	}

	rows, err := d.db.Query("SELECT type, parameters FROM actions WHERE son_id = ?", id)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	for rows.Next() {
		var action models.Action
		var parametersJSON string
		err := rows.Scan(&action.Type, &parametersJSON)
		if err != nil {
			return nil, err
		}
		err = json.Unmarshal([]byte(parametersJSON), &action.Parameters)
		if err != nil {
			return nil, err
		}
		son.Actions = append(son.Actions, action)
	}

	return son, nil
}

func (d *Database) UpdateSon(son *models.Son) error {
	son.UpdatedAt = time.Now()

	tx, err := d.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	_, err = tx.Exec(
		"UPDATE sons SET name = ?, trigger = ?, delay = ?, updated_at = ? WHERE id = ?",
		son.Name, son.Trigger, int64(son.Delay), son.UpdatedAt, son.ID,
	)
	if err != nil {
		return err
	}

	_, err = tx.Exec("DELETE FROM actions WHERE son_id = ?", son.ID)
	if err != nil {
		return err
	}

	for _, action := range son.Actions {
		parametersJSON, err := json.Marshal(action.Parameters)
		if err != nil {
			return err
		}
		_, err = tx.Exec(
			"INSERT INTO actions (son_id, type, parameters) VALUES (?, ?, ?)",
			son.ID, action.Type, string(parametersJSON),
		)
		if err != nil {
			return err
		}
	}

	return tx.Commit()
}

func (d *Database) DeleteSon(id string) error {
	tx, err := d.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	_, err = tx.Exec("DELETE FROM actions WHERE son_id = ?", id)
	if err != nil {
		return err
	}

	result, err := tx.Exec("DELETE FROM sons WHERE id = ?", id)
	if err != nil {
		return err
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if affected == 0 {
		return ErrSonNotFound
	}

	return tx.Commit()
}

func (d *Database) ListSons() ([]models.Son, error) {
	rows, err := d.db.Query("SELECT id, name, trigger, delay, created_at, updated_at FROM sons")
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var sons []models.Son
	for rows.Next() {
		var son models.Son
		err := rows.Scan(&son.ID, &son.Name, &son.Trigger, &son.Delay, &son.CreatedAt, &son.UpdatedAt)
		if err != nil {
			return nil, err
		}
		sons = append(sons, son)
	}

	for i, son := range sons {
		actionRows, err := d.db.Query("SELECT type, parameters FROM actions WHERE son_id = ?", son.ID)
		if err != nil {
			return nil, err
		}
		defer actionRows.Close()

		for actionRows.Next() {
			var action models.Action
			var parametersJSON string
			err := actionRows.Scan(&action.Type, &parametersJSON)
			if err != nil {
				return nil, err
			}
			err = json.Unmarshal([]byte(parametersJSON), &action.Parameters)
			if err != nil {
				return nil, err
			}
			sons[i].Actions = append(sons[i].Actions, action)
		}
	}

	return sons, nil
}

================
File: services/listmonk_client_test.go
================
package services

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestListmonkClient(t *testing.T) {
	// Create a mock server
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/tx":
			w.WriteHeader(http.StatusOK)
			w.Write([]byte(`{"data": true}`))
		case "/api/subscribers":
			w.WriteHeader(http.StatusOK)
			w.Write([]byte(`{"data": {"id": 1}}`))
		case "/api/campaigns":
			w.WriteHeader(http.StatusOK)
			w.Write([]byte(`{"data": {"id": 1}}`))
		default:
			http.Error(w, "Not found", http.StatusNotFound)
		}
	}))
	defer server.Close()

	// Create a client that uses the mock server URL
	client := &ListmonkClient{
		baseURL: server.URL,
		client:  server.Client(),
	}

	// Test SendTransactionalEmail
	err := client.SendTransactionalEmail(1, "test@example.com", map[string]interface{}{"name": "Test"})
	assert.NoError(t, err)

	// Test ManageSubscriber
	err = client.ManageSubscriber("test@example.com", "Test User", "enabled", []int{1})
	assert.NoError(t, err)

	// Test CreateCampaign
	err = client.CreateCampaign("Test Campaign", "Test Subject", []int{1}, 1, "2023-01-01T00:00:00Z")
	assert.NoError(t, err)
}

================
File: services/listmonk_client.go
================
package services

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"

	"github.com/troneras/ghost-listmonk-connector/utils"
)

type ListmonkList struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

type ListmonkTemplate struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

type ListmonkClient struct {
	baseURL string
	client  *http.Client
}

func NewListmonkClient(config *utils.Config) *ListmonkClient {
	return &ListmonkClient{
		baseURL: config.ListmonkURL,
		client:  &http.Client{},
	}
}

func (c *ListmonkClient) GetLists() ([]ListmonkList, error) {
	resp, err := c.client.Get(c.baseURL + "/api/lists?page=1&per_page=100")
	if err != nil {
		return nil, fmt.Errorf("error fetching lists: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	var result struct {
		Data struct {
			Results []ListmonkList `json:"results"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("error decoding response: %w", err)
	}

	return result.Data.Results, nil
}

func (c *ListmonkClient) GetTemplates() ([]ListmonkTemplate, error) {
	resp, err := c.client.Get(c.baseURL + "/api/templates?page=1&per_page=100")
	if err != nil {
		return nil, fmt.Errorf("error fetching templates: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	var result struct {
		Data []ListmonkTemplate `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("error decoding response: %w", err)
	}

	return result.Data, nil
}

func (c *ListmonkClient) SendTransactionalEmail(templateID int, subscriberEmail string, data map[string]interface{}) error {
	payload := map[string]interface{}{
		"subscriber_email": subscriberEmail,
		"template_id":      templateID,
		"data":             data,
	}

	jsonPayload, err := json.Marshal(payload)
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err)
		return fmt.Errorf("failed to marshal payload: %w", err)
	}

	resp, err := c.client.Post(c.baseURL+"/api/tx", "application/json", bytes.NewBuffer(jsonPayload))
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to send transactional email: %v", err)
		return fmt.Errorf("failed to send transactional email: %w", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
		return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
	}

	utils.InfoLogger.Infof("Sent transactional email to %s using template %d", subscriberEmail, templateID)
	return nil
}

func (c *ListmonkClient) ManageSubscriber(email string, name string, status string, lists []int) error {
	payload := map[string]interface{}{
		"email":                    email,
		"name":                     name,
		"status":                   status,
		"lists":                    lists,
		"preconfirm_subscriptions": true,
	}

	jsonPayload, err := json.Marshal(payload)
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err)
		return fmt.Errorf("failed to marshal payload: %w", err)
	}

	resp, err := c.client.Post(c.baseURL+"/api/subscribers", "application/json", bytes.NewBuffer(jsonPayload))
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to manage subscriber: %v", err)
		return fmt.Errorf("failed to manage subscriber: %w", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
		return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
	}

	utils.InfoLogger.Infof("Managed subscriber %s with status %s", email, status)
	return nil
}

func (c *ListmonkClient) CreateCampaign(name string, subject string, lists []int, templateID int, sendAt string) error {
	payload := map[string]interface{}{
		"name":        name,
		"subject":     subject,
		"lists":       lists,
		"template_id": templateID,
		"send_at":     sendAt,
	}

	jsonPayload, err := json.Marshal(payload)
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err)
		return fmt.Errorf("failed to marshal payload: %w", err)
	}

	resp, err := c.client.Post(c.baseURL+"/api/campaigns", "application/json", bytes.NewBuffer(jsonPayload))
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to create campaign: %v", err)
		return fmt.Errorf("failed to create campaign: %w", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
		return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
	}

	utils.InfoLogger.Infof("Created campaign %s with subject %s", name, subject)
	return nil
}

================
File: services/son_executor.go
================
package services

import (
	"fmt"
	"time"

	"github.com/troneras/ghost-listmonk-connector/models"
	"github.com/troneras/ghost-listmonk-connector/utils"
)

type SonExecutor struct {
	listmonkClient *ListmonkClient
}

func NewSonExecutor(listmonkClient *ListmonkClient) *SonExecutor {
	return &SonExecutor{
		listmonkClient: listmonkClient,
	}
}

func (e *SonExecutor) ExecuteSon(son models.Son, data map[string]interface{}) {
	utils.InfoLogger.Infof("Executing Son: %s", son.ID)

	if son.Delay > 0 {
		utils.InfoLogger.Infof("Delaying execution of Son %s for %v", son.ID, time.Duration(son.Delay))
		time.Sleep(time.Duration(son.Delay))
	}

	for _, action := range son.Actions {
		err := e.executeAction(action, data)
		if err != nil {
			utils.ErrorLogger.Errorf("Error executing action for Son %s: %v", son.ID, err)
		}
	}

	utils.InfoLogger.Infof("Finished executing Son: %s", son.ID)
}

func (e *SonExecutor) executeAction(action models.Action, data map[string]interface{}) error {
	utils.InfoLogger.Infof("Executing action: %s", action.Type)

	switch action.Type {
	case models.ActionSendTransactionalEmail:
		return e.sendTransactionalEmail(action.Parameters, data)
	case models.ActionManageSubscriber:
		return e.manageSubscriber(action.Parameters, data)
	case models.ActionCreateCampaign:
		return e.createCampaign(action.Parameters, data)
	default:
		err := fmt.Errorf("unknown action type: %s", action.Type)
		utils.ErrorLogger.Errorf("%v", err)
		return err
	}
}

func (e *SonExecutor) sendTransactionalEmail(params map[string]interface{}, data map[string]interface{}) error {
	templateID := int(params["template_id"].(float64))
	subscriberEmail := data["member"].(map[string]interface{})["current"].(map[string]interface{})["email"].(string)

	utils.InfoLogger.Infof("Sending transactional email to %s using template %d", subscriberEmail, templateID)
	return e.listmonkClient.SendTransactionalEmail(templateID, subscriberEmail, data)
}

func (e *SonExecutor) manageSubscriber(params map[string]interface{}, data map[string]interface{}) error {
	email := data["member"].(map[string]interface{})["current"].(map[string]interface{})["email"].(string)
	name := data["member"].(map[string]interface{})["current"].(map[string]interface{})["name"].(string)
	status := params["status"].(string)
	lists := []int{}
	for _, listID := range params["lists"].([]interface{}) {
		lists = append(lists, int(listID.(float64)))
	}

	utils.InfoLogger.Infof("Managing subscriber %s with status %s", email, status)
	return e.listmonkClient.ManageSubscriber(email, name, status, lists)
}

func (e *SonExecutor) createCampaign(params map[string]interface{}, data map[string]interface{}) error {
	name := params["name"].(string)
	subject := params["subject"].(string)
	lists := []int{}
	for _, listID := range params["lists"].([]interface{}) {
		lists = append(lists, int(listID.(float64)))
	}
	templateID := int(params["template_id"].(float64))
	sendAt := params["send_at"].(string)

	utils.InfoLogger.Infof("Creating campaign %s with subject %s", name, subject)
	return e.listmonkClient.CreateCampaign(name, subject, lists, templateID, sendAt)
}

================
File: services/son_storage_test.go
================
package services

import (
	"os"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/troneras/ghost-listmonk-connector/models"
)

func TestSonStorage(t *testing.T) {
	// Create a temporary database for testing
	tempDBPath := "test_ghost_listmonk.db"
	storage, err := NewSonStorage(tempDBPath)
	assert.NoError(t, err)
	defer os.Remove(tempDBPath) // Clean up the temporary database file after tests

	// Test Create
	son := models.Son{
		Name:    "Test Son",
		Trigger: models.TriggerMemberCreated,
		Delay:   models.Duration(5 * time.Minute),
		Actions: []models.Action{
			{
				Type: models.ActionSendTransactionalEmail,
				Parameters: map[string]interface{}{
					"template_id": float64(1), // Use float64 to match JSON unmarshaling behavior
				},
			},
		},
	}

	err = storage.Create(&son)
	assert.NoError(t, err)
	assert.NotEmpty(t, son.ID) // Ensure an ID was generated

	// Test Get
	retrievedSon, err := storage.Get(son.ID)
	assert.NoError(t, err)
	assert.Equal(t, son.Name, retrievedSon.Name)
	assert.Equal(t, son.Trigger, retrievedSon.Trigger)
	assert.Equal(t, son.Delay, retrievedSon.Delay)
	assert.Len(t, retrievedSon.Actions, 1)
	assert.Equal(t, son.Actions[0].Type, retrievedSon.Actions[0].Type)
	assert.Equal(t, son.Actions[0].Parameters["template_id"], retrievedSon.Actions[0].Parameters["template_id"])

	// Test Update
	son.Name = "Updated Test Son"
	err = storage.Update(son)
	assert.NoError(t, err)

	updatedSon, err := storage.Get(son.ID)
	assert.NoError(t, err)
	assert.Equal(t, "Updated Test Son", updatedSon.Name)

	// Test List
	sons, err := storage.List()
	assert.NoError(t, err)
	assert.Len(t, sons, 1)
	assert.Equal(t, son.ID, sons[0].ID)

	// Test Delete
	err = storage.Delete(son.ID)
	assert.NoError(t, err)

	_, err = storage.Get(son.ID)
	assert.Error(t, err)
	assert.Equal(t, ErrSonNotFound, err)

	// Test error cases
	err = storage.Create(&son)
	assert.NoError(t, err)

	// Attempt to update a non-existent son (should fail)
	nonExistentSon := models.Son{ID: "non-existent", Name: "Non-existent Son"}
	err = storage.Update(nonExistentSon)
	assert.Equal(t, ErrSonNotFound, err)

	err = storage.Delete("non-existent")
	assert.Equal(t, ErrSonNotFound, err)
}

================
File: services/son_storage.go
================
package services

import (
	"database/sql"
	"encoding/json"
	"errors"
	"time"

	_ "github.com/mattn/go-sqlite3"
	"github.com/troneras/ghost-listmonk-connector/models"
	"github.com/troneras/ghost-listmonk-connector/utils"
)

var (
	ErrSonAlreadyExists = errors.New("son with this ID already exists")
	ErrSonNotFound      = errors.New("son not found")
)

type SonStorage struct {
	db *sql.DB
}

func NewSonStorage(dbPath string) (*SonStorage, error) {
	db, err := sql.Open("sqlite3", dbPath)
	if err != nil {
		return nil, err
	}

	if err := db.Ping(); err != nil {
		return nil, err
	}

	storage := &SonStorage{db: db}
	if err := storage.createTables(); err != nil {
		return nil, err
	}

	return storage, nil
}

func (s *SonStorage) createTables() error {
	_, err := s.db.Exec(`
		CREATE TABLE IF NOT EXISTS sons (
			id TEXT PRIMARY KEY,
			name TEXT,
			trigger TEXT,
			delay INTEGER,
			actions TEXT,
			created_at DATETIME,
			updated_at DATETIME
		)
	`)
	return err
}

func (s *SonStorage) Create(son *models.Son) error {
	son.ID = utils.GenerateUUID()
	son.CreatedAt = time.Now()
	son.UpdatedAt = time.Now()

	actionsJSON, err := json.Marshal(son.Actions)
	if err != nil {
		return err
	}

	_, err = s.db.Exec(
		"INSERT INTO sons (id, name, trigger, delay, actions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
		son.ID, son.Name, son.Trigger, int64(son.Delay), actionsJSON, son.CreatedAt, son.UpdatedAt,
	)
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to create Son: %v", err)
		return err
	}

	utils.InfoLogger.Infof("Created new Son with ID: %s", son.ID)
	return nil
}

func (s *SonStorage) Get(id string) (models.Son, error) {
	var son models.Son
	var actionsJSON []byte
	var delayInt int64

	err := s.db.QueryRow(
		"SELECT id, name, trigger, delay, actions, created_at, updated_at FROM sons WHERE id = ?",
		id,
	).Scan(&son.ID, &son.Name, &son.Trigger, &delayInt, &actionsJSON, &son.CreatedAt, &son.UpdatedAt)

	if err != nil {
		if err == sql.ErrNoRows {
			utils.ErrorLogger.Errorf("Failed to get Son: %v", ErrSonNotFound)
			return models.Son{}, ErrSonNotFound
		}
		utils.ErrorLogger.Errorf("Failed to get Son: %v", err)
		return models.Son{}, err
	}

	son.Delay = models.Duration(time.Duration(delayInt))

	err = json.Unmarshal(actionsJSON, &son.Actions)
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to unmarshal actions: %v", err)
		return models.Son{}, err
	}

	utils.InfoLogger.Infof("Retrieved Son with ID: %s", id)
	return son, nil
}

func (s *SonStorage) Update(son models.Son) error {
	son.UpdatedAt = time.Now()

	actionsJSON, err := json.Marshal(son.Actions)
	if err != nil {
		return err
	}

	result, err := s.db.Exec(
		"UPDATE sons SET name = ?, trigger = ?, delay = ?, actions = ?, updated_at = ? WHERE id = ?",
		son.Name, son.Trigger, int64(son.Delay), actionsJSON, son.UpdatedAt, son.ID,
	)
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to update Son: %v", err)
		return err
	}

	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return err
	}

	if rowsAffected == 0 {
		utils.ErrorLogger.Errorf("Failed to update Son: %v", ErrSonNotFound)
		return ErrSonNotFound
	}

	utils.InfoLogger.Infof("Updated Son with ID: %s", son.ID)
	return nil
}

func (s *SonStorage) Delete(id string) error {
	result, err := s.db.Exec("DELETE FROM sons WHERE id = ?", id)
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to delete Son: %v", err)
		return err
	}

	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return err
	}

	if rowsAffected == 0 {
		utils.ErrorLogger.Errorf("Failed to delete Son: %v", ErrSonNotFound)
		return ErrSonNotFound
	}

	utils.InfoLogger.Infof("Deleted Son with ID: %s", id)
	return nil
}

func (s *SonStorage) List() ([]models.Son, error) {
	rows, err := s.db.Query("SELECT id, name, trigger, delay, actions, created_at, updated_at FROM sons")
	if err != nil {
		utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
		return nil, err
	}
	defer rows.Close()

	var sons []models.Son
	for rows.Next() {
		var son models.Son
		var actionsJSON []byte
		var delayInt int64

		err := rows.Scan(&son.ID, &son.Name, &son.Trigger, &delayInt, &actionsJSON, &son.CreatedAt, &son.UpdatedAt)
		if err != nil {
			utils.ErrorLogger.Errorf("Failed to scan Son: %v", err)
			continue
		}

		son.Delay = models.Duration(time.Duration(delayInt))

		err = json.Unmarshal(actionsJSON, &son.Actions)
		if err != nil {
			utils.ErrorLogger.Errorf("Failed to unmarshal actions: %v", err)
			continue
		}

		sons = append(sons, son)
	}

	if sons == nil {
		sons = []models.Son{} // Ensure we always return an array, even if empty
	}

	utils.InfoLogger.Infof("Retrieved list of %d Sons", len(sons))
	return sons, nil
}

================
File: utils/config.go
================
package utils

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

// Config holds all configuration values
type Config struct {
	ListmonkURL string
	APIKey      string
	Port        string
	// Add other configuration fields as needed
}

// LoadConfig reads configuration from .env.local file and environment variables
func LoadConfig() (*Config, error) {
	config := &Config{}

	// Read from .env.local file
	if err := loadEnvFile(".env.local", config); err != nil {
		ErrorLogger.Printf("Error loading .env.local file: %v", err)
		// Continue execution, as we'll fall back to environment variables
	}

	// Override with environment variables if they exist
	if envListmonkURL := os.Getenv("LISTMONK_URL"); envListmonkURL != "" {
		config.ListmonkURL = envListmonkURL
	}
	if envAPIKey := os.Getenv("API_KEY"); envAPIKey != "" {
		config.APIKey = envAPIKey
	}

	if envPort := os.Getenv("API_KEY"); envPort != "" {
		config.Port = envPort
	}

	// Validate required fields
	if config.ListmonkURL == "" {
		return nil, fmt.Errorf("LISTMONK_URL is not set")
	}
	if config.APIKey == "" {
		return nil, fmt.Errorf("API_KEY is not set")
	}
	if config.Port == "" {
		config.Port = "8808"
	}

	return config, nil
}

func loadEnvFile(filename string, config *Config) error {
	file, err := os.Open(filename)
	if err != nil {
		return err
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()
		parts := strings.SplitN(line, "=", 2)
		if len(parts) != 2 {
			continue
		}
		key := strings.TrimSpace(parts[0])
		value := strings.TrimSpace(parts[1])

		switch key {
		case "LISTMONK_URL":
			config.ListmonkURL = value
		case "API_KEY":
			config.APIKey = value
		case "PORT":
			config.Port = value
			// Add other configuration fields as needed
		}
	}

	return scanner.Err()
}

================
File: utils/errors.go
================
package utils

import "fmt"

type CustomError struct {
	Code    string
	Message string
}

func (e *CustomError) Error() string {
	return fmt.Sprintf("%s: %s", e.Code, e.Message)
}

func NewError(code, message string) *CustomError {
	return &CustomError{
		Code:    code,
		Message: message,
	}
}

================
File: utils/logger.go
================
package utils

import (
	"log"
	"os"
)

type Logger struct {
	*log.Logger
}

var (
	InfoLogger  *Logger
	ErrorLogger *Logger
)

func init() {
	InfoLogger = &Logger{log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)}
	ErrorLogger = &Logger{log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)}
}

func (l *Logger) Infof(format string, v ...interface{}) {
	l.Printf(format, v...)
}

func (l *Logger) Errorf(format string, v ...interface{}) {
	l.Printf(format, v...)
}

================
File: utils/pretty_print.go
================
package utils

import (
	"encoding/json"
)

func PrettyPrint(v interface{}) string {
	b, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		return ""
	}
	return string(b)
}

================
File: utils/uuid.go
================
package utils

import (
	"github.com/google/uuid"
)

func GenerateUUID() string {
	return uuid.New().String()
}

================
File: .env.local
================
LISTMONK_URL=http://localhost:9000
NEXT_APP_URL=http://localhost:3000
BASIC_AUTH_USERNAME=username
BASIC_AUTH_PASSWORD=password
API_KEY=your-api-key

================
File: docker-compose.yml
================
version: '3'
services:
  app:
    build: .
    ports:
      - "8808:8808"
    environment:
      - PORT=8808
      - LISTMONK_URL=http://listmonk:9000
      - BASIC_AUTH_USERNAME=admin
      - BASIC_AUTH_PASSWORD=password
    depends_on:
      - listmonk

  listmonk:
    image: listmonk/listmonk:latest
    ports:
      - "9000:9000"
    environment:
      - APP_DB_HOST=db
      - APP_DB_USER=listmonk
      - APP_DB_PASSWORD=listmonk
    depends_on:
      - db

  db:
    image: postgres:13
    environment:
      - POSTGRES_USER=listmonk
      - POSTGRES_PASSWORD=listmonk
      - POSTGRES_DB=listmonk
    volumes:
      - listmonk-data:/var/lib/postgresql/data

volumes:
  listmonk-data:

================
File: Dockerfile
================
FROM golang:1.17-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

FROM alpine:latest  

RUN apk --no-cache add ca-certificates

WORKDIR /root/

COPY --from=builder /app/main .

EXPOSE 8808

CMD ["./main"]

================
File: go.mod
================
module github.com/troneras/ghost-listmonk-connector

go 1.21.6

require (
	github.com/gin-contrib/cors v1.7.2
	github.com/gin-gonic/gin v1.10.0
	github.com/google/uuid v1.6.0
	github.com/mattn/go-sqlite3 v1.14.22
	github.com/stretchr/testify v1.9.0
)

require (
	github.com/bytedance/sonic v1.12.1 // indirect
	github.com/bytedance/sonic/loader v0.2.0 // indirect
	github.com/cloudwego/base64x v0.1.4 // indirect
	github.com/cloudwego/iasm v0.2.0 // indirect
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/gabriel-vasile/mimetype v1.4.5 // indirect
	github.com/gin-contrib/sse v0.1.0 // indirect
	github.com/go-playground/locales v0.14.1 // indirect
	github.com/go-playground/universal-translator v0.18.1 // indirect
	github.com/go-playground/validator/v10 v10.22.0 // indirect
	github.com/goccy/go-json v0.10.3 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/klauspost/cpuid/v2 v2.2.8 // indirect
	github.com/kr/text v0.2.0 // indirect
	github.com/leodido/go-urn v1.4.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
	github.com/modern-go/reflect2 v1.0.2 // indirect
	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
	github.com/ugorji/go/codec v1.2.12 // indirect
	golang.org/x/arch v0.9.0 // indirect
	golang.org/x/crypto v0.26.0 // indirect
	golang.org/x/net v0.28.0 // indirect
	golang.org/x/sys v0.24.0 // indirect
	golang.org/x/text v0.17.0 // indirect
	google.golang.org/protobuf v1.34.2 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

================
File: main.go
================
package main

import (
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"github.com/troneras/ghost-listmonk-connector/handlers"
	"github.com/troneras/ghost-listmonk-connector/middleware"
	"github.com/troneras/ghost-listmonk-connector/services"
	"github.com/troneras/ghost-listmonk-connector/utils"
)

func main() {
	config, err := utils.LoadConfig()
	if err != nil {
		utils.ErrorLogger.Fatalf("Failed to load configuration: %v", err)
	}
	r := gin.Default()

	// CORS configuration
	corsConfig := cors.DefaultConfig()
	corsConfig.AllowOrigins = []string{"http://localhost:3000"} // Add your frontend URL here
	corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
	corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"}
	corsConfig.AllowCredentials = true

	r.Use(cors.New(corsConfig))

	// Initialize services
	sonStorage, err := services.NewSonStorage("ghost_listmonk.db")
	if err != nil {
		utils.ErrorLogger.Fatalf("Failed to initialize son storage: %v", err)
	}

	listmonkClient := services.NewListmonkClient(config)
	sonExecutor := services.NewSonExecutor(listmonkClient)
	sonHandler := handlers.NewSonHandler(sonStorage)
	webhookHandler := handlers.NewWebhookHandler(sonStorage, sonExecutor)
	listmonkHandler := handlers.NewListmonkHandler(listmonkClient)

	// Webhook endpoint (no auth required)
	r.POST("/webhook", webhookHandler.HandleWebhook)

	// Web UI routes (auth required)
	authorized := r.Group("/")
	authorized.Use(middleware.APIKeyAuth(config.APIKey))
	{
		authorized.GET("/", handleHome)

		// Son routes
		sons := authorized.Group("/sons")
		{
			sons.POST("", sonHandler.Create)
			sons.GET("", sonHandler.List)
			sons.GET("/:id", sonHandler.Get)
			sons.PUT("/:id", sonHandler.Update)
			sons.DELETE("/:id", sonHandler.Delete)
		}
		authorized.GET("/lists", listmonkHandler.GetLists)
		authorized.GET("/templates", listmonkHandler.GetTemplates)
	}

	utils.InfoLogger.Infof("Server starting on port %s", config.Port)
	if err := r.Run(":" + config.Port); err != nil {
		utils.ErrorLogger.Fatalf("Failed to start server: %v", err)
	}
}

func handleHome(c *gin.Context) {
	c.JSON(200, gin.H{"message": "Welcome to the Ghost-Listmonk Connector"})
}
