first commit

fixed cache

Refactoring from app router to page router

Refactoring from app router to page router

Add authentication with JWT

base ui done

protect the dashboard

added transitions

styling

type trick added

cleanup

Add enable-disable logic

add transactional emails

Improvements on delay and frontend

added description on select

implement auth with magic link

migrate to mariadb

webhook signature working

Adding async processing with queues

Implemented webhook reply functionality

Add son execution logs

update status logic

Add recent activity monitoring to sons

show son performance in dashboard

add readme

implement listmonk connector

listmonk-connector-v1

Create CODE_OF_CONDUCT.md

Create LICENSE
This commit is contained in:
troneras 2024-08-12 23:54:25 +02:00
parent 1fd7dad8e4
commit ff5b39241a
154 changed files with 9951 additions and 5373 deletions

19
.env.example Normal file
View file

@ -0,0 +1,19 @@
LISTMONK_URL=http://localhost:9000
GIN_MODE=debug
AUTH_USER=your-listmonk-username
AUTH_PASSWORD=your-listmonk-password
JWT_SECRET=your-jwt-secret
FRONTEND_URL=http://localhost:8808
AWS_REGION=your-aws-region
SES_FROM_EMAIL=no-reply@yourdomain.com
AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=YUOUR_AWS_SECRET_ACCESS_KEY
DB_HOST=localhost
DB_PORT=3307
DB_NAME=listmonk_connector
DB_USER=root
DB_PASSWORD=password
REDIS_ADDR=localhost:6379

View file

@ -1,5 +0,0 @@
LISTMONK_URL=http://localhost:9000
NEXT_APP_URL=http://localhost:3000
BASIC_AUTH_USERNAME=username
BASIC_AUTH_PASSWORD=password
API_KEY=your-api-key

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
*.txt
main
.env.local
.env

128
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
x.com/_troneras.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View file

@ -1,22 +1,30 @@
FROM golang:1.17-alpine AS builder
# Build the Next.js app
FROM node:20.10.0 AS nextjs-builder
WORKDIR /app
COPY ui/ ./
RUN npm ci
RUN npm run build
# Build the Go app
FROM golang:1.21.6 AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o main .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
# Final stage
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y ca-certificates nodejs npm && rm -rf /var/lib/apt/lists/*
WORKDIR /root/
COPY --from=go-builder /app/main .
COPY --from=nextjs-builder /app .
COPY start.sh .
COPY --from=builder /app/main .
ENV GIN_MODE=release
ENV NODE_ENV=production
EXPOSE 8808
EXPOSE 8808 3000
CMD ["./main"]
CMD ["./start.sh"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 troneras
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

38
Makefile Normal file
View file

@ -0,0 +1,38 @@
.PHONY: dev dev-backend dev-frontend build-frontend build-all docker-build docker-run test lint
dev:
@echo "Starting development servers..."
@make -j 2 dev-backend dev-frontend
dev-backend:
GIN_MODE=debug go run main.go
dev-frontend:
cd ui && npm run dev
create-migration:
@read -p "Enter migration name: " name; \
migrate create -ext sql -dir database/migrations -seq $$name
build-frontend:
cd ui && npm run build
build-all: build-frontend
GIN_MODE=release go build -o main .
docker-build:
docker build -t ghost-listmonk-connector .
docker-run:
docker run -p 8808:8808 ghost-listmonk-connector
repopack:
npx repopack --ignore "*.txt,*.db,go.sum,ui_backup,**/*.txt,**/ui/*.tsx,**/ui/*.ts,**/.next,**/node_modules,**/pnpm-lock.yaml"
test:
go test ./...
cd ui && npm test
lint:
go vet ./...
cd ui && npm run lint

170
README.md
View file

@ -1 +1,169 @@
# ghost-listmonk-connector
# Ghost-Listmonk Connector
![Ghost-monk](ghost_monk.png "Ghost Monk")
## Table of Contents
- [Introduction](#introduction)
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [API Endpoints](#api-endpoints)
- [Frontend](#frontend)
- [Contributing](#contributing)
- [License](#license)
## Introduction
Ghost-Listmonk Connector is an open-source project that bridges the gap between Ghost CMS and Listmonk, enabling seamless integration of email marketing capabilities with your Ghost blog. This connector allows you to automate subscriber management, trigger email campaigns based on Ghost events, and provides a user-friendly dashboard for monitoring and managing your email marketing efforts.
## Features
- Automatic synchronization of Ghost subscribers with Listmonk
- Trigger-based actions for various Ghost events (e.g., new post published, new member registered)
- Delayed execution of actions. You can use this to create mail chains. For example send a new subscriber emails a day later, a week later, etc.
- Customizable email templates and campaigns (In Listmonk)
- Real-time dashboard for monitoring Son (Subscriber Operations Notifier) performance
- Webhook management for Ghost events
- Caching system for improved performance
- User authentication and authorization
## Demo
(Click the image to see on youtube)
[![GHOST LISTMONK connector video](https://img.youtube.com/vi/XBhrdeZwpqI/0.jpg)](https://www.youtube.com/watch?v=XBhrdeZwpqI)
## Tech Stack
### Backend
- [Gin](https://github.com/gin-gonic/gin): Web framework
- [JWT-Go](https://github.com/golang-jwt/jwt): JSON Web Token authentication
- [MySQL](https://www.mysql.com/): Relational database
- [Redis](https://redis.io/): Caching and task queue
- [Asynq](https://github.com/hibiken/asynq): Distributed task queue
- [Logrus](https://github.com/sirupsen/logrus): Structured logger
### Frontend
- [Next.js](https://nextjs.org/): React framework
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
- [shadcn/ui](https://ui.shadcn.com/): UI component library
- [Recharts](https://recharts.org/): Charting library
## Prerequisites
- Go 1.21 or later
- Node.js 18 or later
- MySQL 8.0 or later
- Redis 6.0 or later
- Ghost CMS instance
- Listmonk instance
## Installation
1. Clone the repository:
```
git clone https://github.com/yourusername/ghost-listmonk-connector.git
cd ghost-listmonk-connector
```
2. Install backend dependencies:
```
go mod tidy
```
3. Install frontend dependencies:
```
cd ui
npm install
```
4. Build the project:
```
make build-all
```
## Configuration
1. Copy the example environment file and edit it with your settings:
```
cp .env.example .env
```
2. Set up your database:
```
make migrate-up
```
3. Configure your Ghost webhook to point to your connector's webhook endpoint.
4. Set up Listmonk API credentials in the `.env` file.
## Usage
1. Start the server:
```
./main
```
2. Access the dashboard at `http://localhost:8808` (or your configured port).
3. Log in using the credentials set in your `.env` file.
4. Create and manage Sons (Subscriber Operations Notifiers) through the dashboard.
## API Endpoints
- `POST /api/auth/magic-link`: Request a magic link for authentication
- `GET /api/auth/verify`: Verify magic link and authenticate user
- `GET /api/sons`: List all Sons
- `POST /api/sons`: Create a new Son
- `GET /api/sons/:id`: Get details of a specific Son
- `PUT /api/sons/:id`: Update a Son
- `DELETE /api/sons/:id`: Delete a Son
- `GET /api/webhook-logs`: Get webhook logs
- `GET /api/son-execution-logs`: Get Son execution logs
- `GET /api/son-stats`: Get Son performance statistics
For a complete API documentation, please refer to the [API Documentation](./docs/API.md).
## Frontend
The frontend is built with Next.js and provides a user-friendly interface for managing Sons, viewing logs, and monitoring performance. Key features include:
- Dashboard with recent activity and Son performance charts
- Son creation and management interface
- Webhook log viewer
- Son execution log viewer
To start the frontend in development mode:
```
cd ui
npm run dev
```
## Contributing
We welcome contributions to the Ghost-Listmonk Connector! Please follow these steps to contribute:
1. Fork the repository
2. Create a new branch: `git checkout -b feature/your-feature-name`
3. Make your changes and commit them: `git commit -m 'Add some feature'`
4. Push to the branch: `git push origin feature/your-feature-name`
5. Submit a pull request
Please make sure to update tests as appropriate and adhere to the [Code of Conduct](CODE_OF_CONDUCT.md).
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

12
create_migration.sh Executable file
View file

@ -0,0 +1,12 @@
#!/bin/bash
# Check if a migration name was provided
if [ $# -eq 0 ]; then
echo "No migration name provided. Usage: ./create_migration.sh <migration_name>"
exit 1
fi
# Create the migration
migrate create -ext sql -dir database/migrations -seq $1
echo "Migration created successfully."

122
database/db.go Normal file
View file

@ -0,0 +1,122 @@
package database
import (
"database/sql"
"fmt"
"os"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/mysql"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/troneras/ghost-listmonk-connector/utils"
)
var db *sql.DB
func InitDB() error {
config := utils.GetConfig()
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",
config.DBUser, config.DBPassword, config.DBHost, config.DBPort, config.DBName)
var err error
db, err = sql.Open("mysql", dsn)
if err != nil {
return fmt.Errorf("failed to open database connection: %w", err)
}
// Configure the connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
if err = db.Ping(); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
return nil
}
func GetDB() *sql.DB {
return db
}
func CloseDB() {
if db != nil {
db.Close()
}
}
func RunMigrations(direction string, steps int) error {
err := InitDB()
if err != nil {
utils.ErrorLogger.Fatalf("Failed to initialize database: %v", err)
}
defer GetDB().Close()
utils.InfoLogger.Printf("Starting migration process. Direction: %s, Steps: %d", direction, steps)
driver, err := mysql.WithInstance(db, &mysql.Config{})
if err != nil {
utils.ErrorLogger.Printf("Failed to create migration driver: %v", err)
return fmt.Errorf("failed to create migration driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance(
"file://database/migrations",
"mysql",
driver,
)
if err != nil {
utils.ErrorLogger.Printf("Failed to create migration instance: %v", err)
return fmt.Errorf("failed to create migration instance: %w", err)
}
defer func() {
srcErr, dbErr := m.Close()
if srcErr != nil {
utils.ErrorLogger.Printf("Error closing migration source: %v", srcErr)
}
if dbErr != nil {
utils.ErrorLogger.Printf("Error closing migration database: %v", dbErr)
}
}()
// Check if any migration files exist
migrations, err := os.ReadDir("database/migrations")
if err != nil {
utils.ErrorLogger.Printf("Failed to read migrations directory: %v", err)
return fmt.Errorf("failed to read migrations directory: %w", err)
}
if len(migrations) == 0 {
utils.InfoLogger.Println("No migration files found. Skipping migration process.")
return nil
}
var migrationErr error
switch direction {
case "up":
if steps > 0 {
migrationErr = m.Steps(steps)
} else {
migrationErr = m.Up()
}
case "down":
if steps > 0 {
migrationErr = m.Steps(-steps)
} else {
migrationErr = m.Down()
}
default:
return fmt.Errorf("invalid migration direction: %s", direction)
}
if migrationErr != nil && migrationErr != migrate.ErrNoChange {
utils.ErrorLogger.Printf("Error applying migrations: %v", migrationErr)
return fmt.Errorf("error applying migrations: %w", migrationErr)
}
utils.InfoLogger.Println("Migrations applied successfully")
return nil
}

View file

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS magic_links;
DROP TABLE IF EXISTS sons;
DROP TABLE IF EXISTS webhooks;
DROP TABLE IF EXISTS users;

View file

@ -0,0 +1,38 @@
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
subscription_level VARCHAR(20) NOT NULL DEFAULT 'free',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE sons (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL,
trigger_event VARCHAR(50) NOT NULL,
delay VARCHAR(50) NOT NULL,
actions JSON NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE magic_links (
token VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
expires_at TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE webhooks (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
endpoint VARCHAR(255) NOT NULL,
secret VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS webhook_logs;

View file

@ -0,0 +1,13 @@
CREATE TABLE webhook_logs (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
method VARCHAR(10) NOT NULL,
path VARCHAR(255) NOT NULL,
headers TEXT NOT NULL,
body TEXT NOT NULL,
status_code INT NOT NULL,
response_body TEXT,
duration INT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS son_execution_logs;

View file

@ -0,0 +1,12 @@
-- 000002_son_execution_logs.up.sql
CREATE TABLE son_execution_logs (
id VARCHAR(36) PRIMARY KEY,
son_id VARCHAR(36) NOT NULL,
webhook_log_id VARCHAR(36) NOT NULL,
execution_status VARCHAR(10) NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
error_message TEXT,
FOREIGN KEY (son_id) REFERENCES sons(id) ON DELETE CASCADE,
FOREIGN KEY (webhook_log_id) REFERENCES webhook_logs(id) ON DELETE CASCADE,
CONSTRAINT chk_execution_status CHECK (execution_status IN ('success', 'failure'))
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS son_execution_action_logs;

View file

@ -0,0 +1,10 @@
CREATE TABLE son_execution_action_logs (
id VARCHAR(36) PRIMARY KEY,
son_execution_log_id VARCHAR(36) NOT NULL,
action_type VARCHAR(50) NOT NULL,
action_status VARCHAR(10) NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
error_message TEXT,
FOREIGN KEY (son_execution_log_id) REFERENCES son_execution_logs(id) ON DELETE CASCADE,
CONSTRAINT chk_action_status CHECK (action_status IN ('success', 'failure'))
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS recent_activity;

View file

@ -0,0 +1,8 @@
CREATE TABLE recent_activity (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
action_type VARCHAR(50) NOT NULL,
description TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View file

@ -0,0 +1 @@
ALTER TABLE webhook_logs MODIFY COLUMN body TEXT;

View file

@ -0,0 +1 @@
ALTER TABLE webhook_logs MODIFY COLUMN body LONGTEXT;

Binary file not shown.

BIN
ghost_monk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

26
go.mod
View file

@ -3,25 +3,40 @@ module github.com/troneras/ghost-listmonk-connector
go 1.21.6
require (
github.com/aws/aws-sdk-go v1.55.5
github.com/gin-contrib/cors v1.7.2
github.com/gin-contrib/sessions v1.0.1
github.com/gin-contrib/static v1.1.2
github.com/gin-gonic/gin v1.10.0
github.com/go-sql-driver/mysql v1.8.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/stretchr/testify v1.9.0
github.com/hibiken/asynq v0.24.1
github.com/redis/go-redis/v9 v9.0.3
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.12.1 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/golang/protobuf v1.5.3 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.2.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jmespath/go-jmespath v0.4.0 // 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
@ -30,14 +45,17 @@ require (
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/robfig/cron/v3 v3.0.1 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/atomic v1.7.0 // 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
golang.org/x/time v0.3.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

138
go.sum
View file

@ -1,8 +1,22 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
@ -11,12 +25,28 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg=
github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA=
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sessions v1.0.1 h1:3hsJyNs7v7N8OtelFmYXFrulAf6zSR7nW/putcPEHxI=
github.com/gin-contrib/sessions v1.0.1/go.mod h1:ouxSFM24/OgIud5MJYQJLpy6AwxQ5EYO9yLhbtObGkM=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=
github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -27,44 +57,97 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hibiken/asynq v0.24.1 h1:+5iIEAyA9K/lcSPvx3qoPtsKJeKI5u9aOIvUmSsazEw=
github.com/hibiken/asynq v0.24.1/go.mod h1:u5qVeSbrnfT+vtG5Mq8ZPzQu/BmCKMHvTGb91uy9Tts=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.0.3 h1:+7mmR26M0IvyLxGZUHxu4GiBkJkVDid0Un+j4ScYu4k=
github.com/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -77,25 +160,72 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

97
handlers/auth.go Normal file
View file

@ -0,0 +1,97 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/troneras/ghost-listmonk-connector/services"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type AuthHandler struct {
userService *services.UserService
magicLinkService *services.MagicLinkService
emailService *services.EmailService
}
func NewAuthHandler(userService *services.UserService, magicLinkService *services.MagicLinkService, emailService *services.EmailService) *AuthHandler {
return &AuthHandler{
userService: userService,
magicLinkService: magicLinkService,
emailService: emailService,
}
}
func (h *AuthHandler) RequestMagicLink(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userService.GetUserByEmail(req.Email)
if err != nil {
// If user doesn't exist, create a new one
utils.InfoLogger.Printf("User with email %s not found, creating new user", req.Email)
user, err = h.userService.CreateUser(req.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
}
token, err := h.magicLinkService.CreateToken(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create magic link"})
return
}
magicLink := utils.GetConfig().FrontendURL + "/auth/verify?token=" + token
// Send email with magic link
err = h.emailService.SendMagicLinkEmail(user.Email, magicLink)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send magic link email", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Magic link sent to your email"})
}
func (h *AuthHandler) VerifyMagicLink(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Token is required"})
return
}
userID, err := h.magicLinkService.VerifyToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
user, err := h.userService.GetUserByID(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
// Generate JWT token
jwtToken, err := utils.GenerateJWT(user.ID, user.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": jwtToken,
"user": gin.H{
"id": user.ID,
"email": user.Email,
},
})
}

47
handlers/handlers.go Normal file
View file

@ -0,0 +1,47 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/troneras/ghost-listmonk-connector/services"
)
type Handlers struct {
Auth *AuthHandler
Son *SonHandler
Webhook *WebhookHandler
Listmonk *ListmonkHandler
Home *HomeHandler
WebhookLog *WebhookLogHandler
SonExecutionLog *SonExecutionLogHandler
RecentActivity *RecentActivityHandler
SonStats *SonStatsHandler
}
func NewHandlers(services *services.Services) *Handlers {
return &Handlers{
Auth: NewAuthHandler(services.User, services.MagicLink, services.Email),
Son: NewSonHandler(services.SonStorage),
Webhook: NewWebhookHandler(services.SonStorage, services.SonExecutor, services.Webhook, services.WebhookLogger),
Listmonk: NewListmonkHandler(services.ListmonkClient),
Home: NewHomeHandler(),
WebhookLog: NewWebhookLogHandler(services.WebhookLogger),
SonExecutionLog: NewSonExecutionLogHandler(services.SonExecutionLogger),
RecentActivity: NewRecentActivityHandler(services.RecentActivity),
SonStats: NewSonStatsHandler(services.SonExecutionLogger),
}
}
// HomeHandler handles the home route
type HomeHandler struct{}
func NewHomeHandler() *HomeHandler {
return &HomeHandler{}
}
func (h *HomeHandler) HandleHome(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Welcome to the Ghost-Listmonk Connector API"})
}
// Implement NewWebhookLogHandler and WebhookLogHandler methods here

View file

@ -0,0 +1,39 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/troneras/ghost-listmonk-connector/services"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type ListmonkHandler struct {
client *services.ListmonkClient
}
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})
}

View file

@ -0,0 +1,35 @@
// handlers/recent_activity_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"
)
type RecentActivityHandler struct {
service *services.RecentActivityService
}
func NewRecentActivityHandler(service *services.RecentActivityService) *RecentActivityHandler {
return &RecentActivityHandler{service: service}
}
func (h *RecentActivityHandler) GetRecentActivity(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
activities, err := h.service.GetRecentActivity(currentUser.ID, 10) // Get last 10 activities
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch recent activities"})
return
}
c.JSON(http.StatusOK, activities)
}

View file

@ -0,0 +1,72 @@
// handlers/son_execution_log_handler.go
package handlers
import (
"net/http"
"strconv"
"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 SonExecutionLogHandler struct {
logger *services.SonExecutionLogger
}
func NewSonExecutionLogHandler(logger *services.SonExecutionLogger) *SonExecutionLogHandler {
return &SonExecutionLogHandler{
logger: logger,
}
}
func (h *SonExecutionLogHandler) GetSonExecutionLogs(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
logs, total, err := h.logger.GetSonExecutionLogs(currentUser.ID, limit, offset)
if err != nil {
utils.ErrorLogger.Printf("Failed to fetch son execution logs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch son execution logs"})
return
}
if logs == nil {
logs = []models.SonExecutionLog{}
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"pagination": gin.H{
"total": total,
"limit": limit,
"offset": offset,
},
})
}
func (h *SonExecutionLogHandler) GetActionExecutionLogs(c *gin.Context) {
executionID := c.Param("executionId")
logs, err := h.logger.GetActionExecutionLogs(executionID)
if err != nil {
utils.ErrorLogger.Printf("Failed to fetch action execution logs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch action execution logs"})
return
}
if logs == nil {
logs = []models.ActionExecutionLog{}
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
}

View file

@ -13,41 +13,20 @@ 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) {
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
var son models.Son
if err := c.ShouldBindJSON(&son); err != nil {
utils.ErrorLogger.Errorf("Invalid Son data: %v", err)
@ -55,6 +34,34 @@ func (h *SonHandler) Create(c *gin.Context) {
return
}
son.ID = utils.GenerateUUID()
son.UserID = currentUser.ID
// Check user's subscription level and apply limits
var maxSons int
switch currentUser.SubscriptionLevel {
case models.SubscriptionFree:
maxSons = 5
case models.SubscriptionPremium:
maxSons = 20
case models.SubscriptionBusiness:
maxSons = 100
default:
maxSons = 1
}
existingSons, err := h.storage.List(currentUser.ID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing Sons"})
return
}
if len(existingSons) >= maxSons {
c.JSON(http.StatusForbidden, gin.H{"error": "Son limit reached for your subscription level"})
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()})
@ -66,6 +73,15 @@ func (h *SonHandler) Create(c *gin.Context) {
}
func (h *SonHandler) Get(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
id := c.Param("id")
son, err := h.storage.Get(id)
if err != nil {
@ -79,11 +95,26 @@ func (h *SonHandler) Get(c *gin.Context) {
return
}
if son.UserID != currentUser.ID {
utils.ErrorLogger.Errorf("Unauthorized access to Son: %s", id)
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized access to Son"})
return
}
utils.InfoLogger.Infof("Retrieved Son: %s", utils.PrettyPrint(son))
c.JSON(http.StatusOK, son)
}
func (h *SonHandler) Update(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
id := c.Param("id")
var son models.Son
if err := c.ShouldBindJSON(&son); err != nil {
@ -93,6 +124,7 @@ func (h *SonHandler) Update(c *gin.Context) {
}
son.ID = id
son.UserID = currentUser.ID
if err := h.storage.Update(son); err != nil {
if err == services.ErrSonNotFound {
utils.ErrorLogger.Errorf("Son not found for update: %s", id)
@ -109,8 +141,17 @@ func (h *SonHandler) Update(c *gin.Context) {
}
func (h *SonHandler) Delete(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
id := c.Param("id")
if err := h.storage.Delete(id); err != nil {
if err := h.storage.Delete(id, currentUser.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"})
@ -126,7 +167,16 @@ func (h *SonHandler) Delete(c *gin.Context) {
}
func (h *SonHandler) List(c *gin.Context) {
sons, err := h.storage.List()
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
sons, err := h.storage.List(currentUser.ID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)

View file

@ -0,0 +1,38 @@
// handlers/son_stats_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"
)
type SonStatsHandler struct {
logger *services.SonExecutionLogger
}
func NewSonStatsHandler(logger *services.SonExecutionLogger) *SonStatsHandler {
return &SonStatsHandler{logger: logger}
}
func (h *SonStatsHandler) GetSonStats(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
timeframe := c.DefaultQuery("timeframe", "24h")
stats, err := h.logger.GetSonStats(c, currentUser.ID, timeframe)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch son statistics"})
return
}
c.JSON(http.StatusOK, stats)
}

View file

@ -1,7 +1,16 @@
package handlers
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/troneras/ghost-listmonk-connector/models"
@ -10,22 +19,61 @@ import (
)
type WebhookHandler struct {
sonStorage *services.SonStorage
executor *services.SonExecutor
sonStorage *services.SonStorage
executor *services.SonExecutor
webhookService *services.WebhookService
webhookLogger *services.WebhookLogger
}
func NewWebhookHandler(sonStorage *services.SonStorage, executor *services.SonExecutor) *WebhookHandler {
func NewWebhookHandler(sonStorage *services.SonStorage, executor *services.SonExecutor, webhookService *services.WebhookService, webhookLogger *services.WebhookLogger) *WebhookHandler {
return &WebhookHandler{
sonStorage: sonStorage,
executor: executor,
sonStorage: sonStorage,
executor: executor,
webhookService: webhookService,
webhookLogger: webhookLogger,
}
}
func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
startTime := time.Now()
endpoint := c.Param("endpoint")
webhook, err := h.webhookService.GetWebhookByEndpoint(endpoint)
if err != nil {
utils.ErrorLogger.Printf("Webhook not found: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"})
return
}
// Read the request body
body, err := io.ReadAll(c.Request.Body)
if err != nil {
utils.ErrorLogger.Printf("Failed to read request body: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}
// Create initial webhook log
webhookLogID, err := h.webhookLogger.CreateWebhookLog(webhook.UserID, c.Request, body)
if err != nil {
utils.ErrorLogger.Printf("Failed to create webhook log: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to log webhook"})
return
}
// Verify the webhook signature
signature := c.GetHeader("x-ghost-signature")
if !verifySignature(signature, body, webhook.Secret) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusUnauthorized, gin.H{"error": "Invalid signature"}, time.Since(startTime))
return
}
var webhookData map[string]interface{}
if err := c.ShouldBindJSON(&webhookData); err != nil {
if err := json.Unmarshal(body, &webhookData); err != nil {
utils.ErrorLogger.Errorf("Invalid webhook data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook data"})
h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusBadRequest, gin.H{"error": "Invalid webhook data"}, time.Since(startTime))
return
}
@ -35,63 +83,208 @@ func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
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"})
h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusBadRequest, gin.H{"error": "Unable to determine trigger type"}, time.Since(startTime))
return
}
utils.InfoLogger.Infof("Determined trigger type: %s", triggerType)
// Find and execute relevant Sons
sons, err := h.sonStorage.List()
sons, err := h.sonStorage.List(webhook.UserID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list Sons"})
h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusInternalServerError, gin.H{"error": "Failed to list Sons"}, time.Since(startTime))
return
}
executedCount := 0
for _, son := range sons {
if son.Trigger == triggerType {
if son.Trigger == triggerType && son.Enabled {
go func(s models.Son) {
utils.InfoLogger.Infof("Executing Son %s for trigger %s", s.ID, triggerType)
h.executor.ExecuteSon(s, webhookData)
h.executor.ExecuteSon(s, webhookData, webhookLogID)
}(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})
// Prepare response
response := gin.H{"message": "Webhook processed successfully", "sons_executed": executedCount}
c.JSON(http.StatusOK, response)
// Update the webhook log
duration := time.Since(startTime)
err = h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusOK, response, duration)
if err != nil {
utils.ErrorLogger.Errorf("Failed to update webhook log: %v", err)
}
}
func (h *WebhookHandler) ReplayWebhook(c *gin.Context) {
logID := c.Param("id")
// Get the original webhook log
log, err := h.webhookLogger.GetWebhookLogForReplay(logID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to get webhook log for replay: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook log not found"})
return
}
// Construct the webhook URL
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
// Check for X-Forwarded-Proto header
if forwardedProto := c.GetHeader("X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
}
webhookURL := fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, log.Path)
req, err := http.NewRequest(log.Method, webhookURL, bytes.NewBuffer([]byte(log.Body)))
if err != nil {
utils.ErrorLogger.Errorf("Failed to create replay request: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create replay request"})
return
}
// Set the original headers
var headers map[string]string
if err := json.Unmarshal([]byte(log.Headers), &headers); err != nil {
utils.ErrorLogger.Errorf("Failed to unmarshal headers: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process headers"})
return
}
for k, v := range headers {
req.Header.Set(k, v)
}
// Add a custom header to indicate this is a replay
req.Header.Set("X-Webhook-Replay", "true")
// Send the request to our own webhook endpoint
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
utils.ErrorLogger.Errorf("Failed to send replay request: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to replay webhook"})
return
}
defer resp.Body.Close()
// Read the response
respBody, err := io.ReadAll(resp.Body)
if err != nil {
utils.ErrorLogger.Errorf("Failed to read replay response: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process replay response"})
return
}
// Return the response from the webhook endpoint
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody)
}
func (h *WebhookHandler) GetWebhookInfo(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
webhooks, err := h.webhookService.GetWebhooksByUserID(currentUser.ID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to get webhooks: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get webhooks"})
return
}
if len(webhooks) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "No webhook found for user"})
return
}
webhooks[0].Endpoint = utils.GetConfig().FrontendURL + "/webhook/" + webhooks[0].Endpoint
// For now, we'll just return the first webhook
c.JSON(http.StatusOK, gin.H{"data": webhooks[0]})
}
func verifySignature(signature string, payload []byte, secret string) bool {
// Split the signature into its components
parts := strings.Split(signature, ", ")
if len(parts) != 2 {
utils.ErrorLogger.Println("Invalid signature format")
return false
}
receivedSignature := strings.TrimPrefix(parts[0], "sha256=")
timestamp := strings.TrimPrefix(parts[1], "t=")
// Append the timestamp to the payload
fullPayload := append(payload, []byte(timestamp)...)
// Compute the expected signature
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(fullPayload)
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Compare the signatures
if receivedSignature != expectedSignature {
utils.ErrorLogger.Printf("Signature mismatch. Received: %s, Expected: %s", receivedSignature, expectedSignature)
return false
}
return true
}
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["current"].(map[string]interface{}); ok {
if previous, ok := member["previous"].(map[string]interface{}); ok && len(previous) > 0 {
// log member["previous"]
utils.InfoLogger.Infof("Previous member data: %v", previous)
utils.InfoLogger.Infof("Member updated. Previous data: %v", previous)
return models.TriggerMemberUpdated, nil
}
utils.InfoLogger.Infof("New member created")
return models.TriggerMemberCreated, nil
}
// Assuming member deletion doesn't have 'current' field
utils.InfoLogger.Infof("Member deleted. Previous data: %v", member["previous"])
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 current, ok := post["current"].(map[string]interface{}); ok {
if status, ok := current["status"].(string); ok {
switch status {
case "published":
utils.InfoLogger.Infof("Post published")
return models.TriggerPostPublished, nil
case "scheduled":
utils.InfoLogger.Infof("Post scheduled")
return models.TriggerPostScheduled, nil
default:
utils.InfoLogger.Infof("Unhandled post status: %s", status)
}
}
}
}
if page, ok := webhookData["page"].(map[string]interface{}); ok {
if status, ok := page["status"].(string); ok && status == "published" {
return models.TriggerPagePublished, nil
if current, ok := page["current"].(map[string]interface{}); ok {
if status, ok := current["status"].(string); ok && status == "published" {
utils.InfoLogger.Infof("Page published")
return models.TriggerPagePublished, nil
}
}
}
// Log the entire webhook data for debugging
utils.ErrorLogger.Printf("Unable to determine trigger type. Webhook data: %+v", webhookData)
return "", utils.NewError("UnknownTriggerType", "Unable to determine trigger type from webhook data")
}

View file

@ -0,0 +1,86 @@
package handlers
import (
"net/http"
"strconv"
"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 WebhookLogHandler struct {
logger *services.WebhookLogger
}
func NewWebhookLogHandler(logger *services.WebhookLogger) *WebhookLogHandler {
return &WebhookLogHandler{
logger: logger,
}
}
// GetLogs retrieves a paginated list of webhook logs for the current user
func (h *WebhookLogHandler) GetLogs(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
logs, total, err := h.logger.GetWebhookLogs(currentUser.ID, limit, offset)
if err != nil {
utils.ErrorLogger.Printf("Failed to fetch webhook logs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch webhook logs"})
return
}
nextOffset := offset + limit
if nextOffset >= total {
nextOffset = -1 // Indicate that there are no more pages
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"pagination": gin.H{
"total": total,
"limit": limit,
"offset": offset,
"next_offset": nextOffset,
},
})
}
// GetLogDetails retrieves the details of a specific webhook log
func (h *WebhookLogHandler) GetLogDetails(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
logID := c.Param("id")
log, err := h.logger.GetWebhookLogDetails(logID)
if err != nil {
utils.ErrorLogger.Printf("Failed to fetch webhook log details: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook log not found"})
return
}
// Ensure the log belongs to the current user
if log.UserID != currentUser.ID {
utils.ErrorLogger.Printf("User %s attempted to access log %s belonging to another user", currentUser.ID, logID)
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
c.JSON(http.StatusOK, log)
}

161
main.go
View file

@ -1,70 +1,147 @@
package main
import (
"flag"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/troneras/ghost-listmonk-connector/database"
"github.com/troneras/ghost-listmonk-connector/handlers"
"github.com/troneras/ghost-listmonk-connector/middleware"
"github.com/troneras/ghost-listmonk-connector/routes"
"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)
// Define flags
migrateFlag := flag.String("migrate", "", "Run database migrations. Use 'up' or 'down'")
migrateSteps := flag.Int("steps", 0, "Number of migration steps to run")
flag.Parse()
// Check if migrations should be run
if *migrateFlag != "" {
if err := runMigrations(*migrateFlag, *migrateSteps); err != nil {
utils.ErrorLogger.Fatalf("Failed to run migrations: %v", err)
}
return
}
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
config := utils.GetConfig()
r.Use(cors.New(corsConfig))
// Initialize database connection
if err := database.InitDB(); err != nil {
utils.ErrorLogger.Fatalf("Failed to initialize database: %v", err)
}
defer database.CloseDB()
// Initialize services
sonStorage, err := services.NewSonStorage("ghost_listmonk.db")
services, err := services.NewServices(config)
if err != nil {
utils.ErrorLogger.Fatalf("Failed to initialize son storage: %v", err)
utils.ErrorLogger.Fatalf("Failed to initialize services: %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)
// Start SonExecutor
go func() {
if err := services.SonExecutor.Start(); err != nil {
utils.ErrorLogger.Fatalf("Failed to start SonExecutor: %v", err)
}
authorized.GET("/lists", listmonkHandler.GetLists)
authorized.GET("/templates", listmonkHandler.GetTemplates)
}
}()
defer services.SonExecutor.Stop()
// Initialize handlers
handlers := handlers.NewHandlers(services)
// Set up Gin
r := setupGin(config)
// Set up routes
routes.SetupRoutes(r, handlers, services)
// Set up static file serving and catch-all route
setupStaticAndCatchAll(r)
// Start the server
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"})
func setupGin(config *utils.Config) *gin.Engine {
r := gin.New()
r.RedirectTrailingSlash = true
// Set up the cookie store
store := cookie.NewStore([]byte(os.Getenv("SESSION_SECRET")))
r.Use(sessions.Sessions("mysession", store))
r.Use(gin.Logger())
r.Use(gin.Recovery())
// CORS configuration
corsConfig := cors.DefaultConfig()
corsConfig.AllowOrigins = []string{config.FrontendURL}
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))
return r
}
func setupStaticAndCatchAll(r *gin.Engine) {
if gin.Mode() == gin.ReleaseMode {
// Production mode
r.Use(static.Serve("/", static.LocalFile("./ui/out", false)))
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
// Check for the .html file
htmlFilePath := filepath.Join("./ui/out", path+".html")
if info, err := os.Stat(htmlFilePath); err == nil && !info.IsDir() {
c.File(htmlFilePath)
return
}
// Check for the file without .html extension
filePath := filepath.Join("./ui/out", path)
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
c.File(filePath)
return
}
// If no specific file is found, serve index.html
c.File("./ui/out/index.html")
})
} else {
// Development mode
nextJSURL, err := url.Parse("http://localhost:3000")
if err != nil {
utils.ErrorLogger.Fatalf("Failed to parse Next.js URL: %v", err)
}
proxy := httputil.NewSingleHostReverseProxy(nextJSURL)
r.NoRoute(func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
})
}
}
func runMigrations(direction string, steps int) error {
if err := database.InitDB(); err != nil {
return err
}
defer database.CloseDB()
return database.RunMigrations(direction, steps)
}

View file

@ -2,23 +2,72 @@ package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/troneras/ghost-listmonk-connector/services"
"github.com/troneras/ghost-listmonk-connector/utils"
)
func APIKeyAuth(validAPIKey string) gin.HandlerFunc {
func AuthRequired(userService *services.UserService) 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"})
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
utils.ErrorLogger.Println("Authorization header is required")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
c.Abort()
return
}
c.Next()
bearerToken := strings.Split(authHeader, " ")
if len(bearerToken) != 2 {
utils.ErrorLogger.Println("Invalid token format")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token format"})
c.Abort()
return
}
tokenString := bearerToken[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(utils.GetConfig().JWT_SECRET), nil
})
if err != nil {
utils.ErrorLogger.Println("Invalid token")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userID, ok := claims["user_id"].(string)
if !ok {
utils.ErrorLogger.Println("Invalid user_id in token claims")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
user, err := userService.GetUserByID(userID)
if err != nil {
utils.ErrorLogger.Printf("Failed to get user: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
c.Abort()
return
}
c.Set("user", user)
c.Next()
} else {
utils.ErrorLogger.Println("Invalid token claims")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
}
}

11
models/recent_activity.go Normal file
View file

@ -0,0 +1,11 @@
package models
import "time"
type RecentActivity struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ActionType string `json:"action_type"`
Description string `json:"description"`
Timestamp time.Time `json:"timestamp"`
}

View file

@ -2,8 +2,9 @@ package models
import (
"encoding/json"
"errors"
"time"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type TriggerType string
@ -26,12 +27,14 @@ const (
type Son struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Trigger TriggerType `json:"trigger"`
Delay Duration `json:"delay"`
Delay string `json:"delay"`
Actions []Action `json:"actions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Enabled bool `json:"enabled"`
}
type Action struct {
@ -39,30 +42,34 @@ type Action struct {
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 {
func (s *Son) UnmarshalJSON(data []byte) error {
type Alias Son
aux := &struct {
*Alias
Delay string `json:"delay"`
}{
Alias: (*Alias)(s),
}
if err := json.Unmarshal(data, &aux); 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")
}
s.Delay = aux.Delay
return nil
}
func (s Son) MarshalJSON() ([]byte, error) {
type Alias Son
return json.Marshal(&struct {
*Alias
Delay string `json:"delay"`
}{
Alias: (*Alias)(&s),
Delay: s.Delay,
})
}
func (s *Son) GetParsedDelay() (time.Duration, error) {
return utils.ParseDuration(s.Delay)
}

View file

@ -0,0 +1,24 @@
// models/execution_log.go
package models
import (
"time"
)
type SonExecutionLog struct {
ID string `json:"id"`
SonID string `json:"son_id"`
WebhookLogID string `json:"webhook_log_id"`
Status string `json:"status"`
ExecutedAt time.Time `json:"executed_at"`
ErrorMessage string `json:"error_message"`
}
type ActionExecutionLog struct {
ID string `json:"id"`
ExecutionLogID string `json:"execution_log_id"`
ActionType string `json:"action_type"`
Status string `json:"status"`
ExecutedAt time.Time `json:"executed_at"`
ErrorMessage string `json:"error_message"`
}

29
models/user.go Normal file
View file

@ -0,0 +1,29 @@
package models
import (
"time"
)
type Role string
const (
RoleUser Role = "user"
RoleAdmin Role = "admin"
)
type SubscriptionLevel string
const (
SubscriptionFree SubscriptionLevel = "free"
SubscriptionPremium SubscriptionLevel = "premium"
SubscriptionBusiness SubscriptionLevel = "business"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Role Role `json:"role"`
SubscriptionLevel SubscriptionLevel `json:"subscription_level"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

14
models/webhook.go Normal file
View file

@ -0,0 +1,14 @@
package models
import (
"time"
)
type Webhook struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
Secret string `json:"secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View file

@ -1,240 +0,0 @@
Are you familiar with Ghost blog platform and listmonk? EditI'm familiar with the Ghost blogging platform, but I don't have detailed knowledge about listmonk. I can share what I know about Ghost and offer to discuss it further if you'd like. Would you like me to elaborate on Ghost, or do you have any specific questions about either platform? CopyRetryAListmonk is an opensource maillist/newsletter manager, it allows you to use Amazon SES for example to send mails. Ghost, on the other side only has integration with Mailchimp, that uses mailjet, what adds a recurring cost. I EditThank you for providing that information about listmonk. It's helpful to understand its functionality as an open-source mailing list and newsletter manager with the ability to use services like Amazon SES for sending emails.
You're correct about Ghost's native integration with Mailchimp. However, it's worth noting that Ghost does have some flexibility when it comes to email services:
Native Mailchimp integration: As you mentioned, this is the built-in option that comes with Ghost, which can indeed add recurring costs.
Custom SMTP: Ghost allows you to use a custom SMTP server for sending emails. This means you could potentially set up your own email sending solution or use a different third-party service.
API and webhooks: Ghost has a robust API and webhook system that allows for custom integrations. While it might require some development work, it's possible to create custom integrations with other email services or tools.
Given your interest in both Ghost and listmonk, it sounds like you might be looking for a more cost-effective or flexible email solution for a Ghost-based blog. Is that correct? If so, I can suggest a few potential approaches or discuss how you might integrate these systems. CopyRetryAThis is in part true, Ghost allows you to use a custom SMTP, but only for transactional emails, for newsletter ghost only allows you to use mailgun EditI appreciate you correcting and clarifying that information. You're absolutely right, and I apologize for my mistake. Let me restate the correct information:
Ghost's newsletter functionality: As you pointed out, Ghost only allows Mailgun for sending newsletters. This is indeed a limitation if you're looking for more flexibility or cost-effective options for newsletter distribution.
Transactional emails: Ghost does allow custom SMTP for transactional emails (like password resets, notifications, etc.), but not for newsletters.
This limitation with Ghost's newsletter functionality can indeed be frustrating for users who want more control over their email infrastructure or are looking to optimize costs.
Given this situation, I can see why you're interested in potentially using listmonk alongside Ghost. Some possible approaches could include:
Using Ghost for your blog content and listmonk for managing your newsletter separately.
Exploring if there's a way to integrate listmonk with Ghost using Ghost's API or webhooks, though this would likely require custom development.
Investigating if there are any community-developed plugins or integrations that might bridge Ghost and listmonk or other email services.
Would you like to discuss any of these options further, or do you have a specific goal in mind for combining Ghost and listmonk? CopyRetryAYes, I would like to manage my blog content with Ghost and my newsletter with ListMonk. When a new user registers to the newsletter, I can send a webhook from ghost where I want. I could use this information to then register this user in listmonk. EditThat's a great approach! You're on the right track with using Ghost's webhook functionality to connect it with listmonk. Here's a high-level overview of how you could set this up:
Ghost Configuration:
Set up a webhook in Ghost that triggers when a new newsletter subscriber is added.
Configure the webhook to send relevant user data (like email address) to an endpoint you control.
Intermediate API/Service:
Create a simple API or serverless function (using AWS Lambda, Google Cloud Functions, or similar) that receives the webhook from Ghost.
This service would process the incoming data from Ghost and then make an API call to listmonk to add the new subscriber.
Listmonk Configuration:
Set up listmonk and configure it to use your preferred email sending service (like Amazon SES).
Ensure listmonk's API is accessible to your intermediate service.
Integration Flow:
User subscribes on your Ghost blog
Ghost sends webhook to your intermediate service
Your service processes the data and calls listmonk's API
Listmonk adds the new subscriber to your mailing list
This setup allows you to maintain Ghost for content management while leveraging listmonk's more flexible and potentially cost-effective email management capabilities.
Would you like me to elaborate on any part of this process, such as setting up the webhook in Ghost or creating the intermediate service? CopyRetryAI'm thinking if handling it myself... my use case is that I've a google form collecting tens of thousands o applications and I need to reply to those people in masse (a few thousands per day), can you do some back of the envelope calculation of what the cost would be using lambda? EditCertainly, I can help you with a rough cost estimation for using AWS Lambda to handle this task. Let's break it down step by step:
AWS Lambda Cost EstimationClick to open document
Based on this calculation, using AWS Lambda for processing your email responses would be very cost-effective. The workload you described (a few thousand emails per day, totaling tens of thousands per month) falls well within AWS Lambda's free tier.
Key points:
Cost: The processing itself would likely be free, assuming you're not using the Lambda free tier for other purposes.
Scalability: Lambda can easily handle this workload and scale up if needed.
Additional costs: Remember that this calculation doesn't include the cost of actually sending the emails. You'd need to factor in the costs of a service like Amazon SES (Simple Email Service) for sending the emails.
SES Costs: As a rough estimate, Amazon SES charges $0.10 per 1,000 emails for the first 62,000 emails each month, and $0.10 per 10,000 emails thereafter. So for 300,000 emails per month, the SES cost would be approximately $9.80.
Would you like me to elaborate on any part of this calculation or discuss other aspects of implementing this solution? CopyRetryAThe other option would be to deploy a connector service, I could probably write this in go and host it alongside my listmonk and ghost EditThat's an excellent alternative approach. Developing a custom connector service in Go and hosting it alongside your Ghost and listmonk instances has several advantages:
Cost-effective: You'd avoid additional cloud service costs for functions like AWS Lambda.
Control: Full control over the integration logic and deployment.
Performance: Go is known for its efficiency and low resource usage, making it ideal for this kind of service.
Simplicity: Keeping everything on the same infrastructure can simplify management and troubleshooting.
Here's a high-level outline of how you might structure this:
Go Connector Service:
Create a small HTTP server in Go that listens for webhooks from Ghost.
Process the incoming data and use listmonk's API to add new subscribers.
Optionally, add logging and error handling for monitoring.
Deployment:
Deploy this Go service on the same server or container environment where you're running Ghost and listmonk.
Ensure proper network configuration so Ghost can reach your connector, and your connector can reach listmonk.
Configuration:
Set up Ghost to send webhooks to your Go connector service when new subscribers sign up.
Configure the connector with the necessary API details for listmonk.
This approach would be particularly beneficial if you're already comfortable with Go and have the infrastructure in place to host additional services. It provides a lightweight, efficient solution that's fully under your control.
Would you like me to sketch out a basic structure for this Go connector service? Or do you have any specific questions about implementing this approach? CopyRetrylistmonk_apiyamlAExample webhook sent when I registered a user:Copy{
"member": {
"current": {
"id": "66b68e4d46c2c0000128b809",
"uuid": "c3570109-5572-4ae5-91be-f4bfe2f02653",
"email": "connect@radianceflow.com",
"name": null,
"note": null,
"geolocation": "{\"area_code\":\"0\",\"country_code\":\"ES\",\"country_code3\":\"ESP\",\"continent_code\":\"EU\",\"asn\":13335,\"region\":\"Madrid\",\"latitude\":\"40.4163\",\"longitude\":\"-3.6934\",\"ip\":\"162.158.122.19\",\"organization_name\":\"CLOUDFLARENET\",\"organization\":\"AS13335 CLOUDFLARENET\",\"timezone\":\"Europe/Madrid\",\"city\":\"Madrid\",\"accuracy\":20,\"country\":\"Spain\"}",
"subscribed": true,
"created_at": "2024-08-09T21:46:53.000Z",
"updated_at": "2024-08-09T21:46:53.000Z",
"labels": [],
"subscriptions": [],
"avatar_image": "https://www.gravatar.com/avatar/e7e64e923ef0d0a0967445b2b73c7144?s=250&r=g&d=blank",
"comped": false,
"email_count": 0,
"email_opened_count": 0,
"email_open_rate": null,
"status": "free",
"last_seen_at": null,
"tiers": [],
"newsletters": [
{
"id": "66b64b106243d80001ac9843",
"name": "troneras",
"description": null,
"status": "active"
}
]
},
"previous": {}
}
}We will need a service that can be deployed with docker compose, it should handle the webhooks from ghost, so it should have a custom server listening for them for example on service:8808/webhookIt should also have the domain where listmonk is in order to call it's API. Management will be done from a web interface. So we need to choose carefully the go framework. For authentication on the webui we can use basic auth or similar and configure this via env variables. The functionality of the UI will be as follows:Similar to Zapier, the user will be able to create multiple Zaps, lets call it something else to avoid branding conflicts, we could call it Sons, from Sonnet ;) In the Zap, sorry, I mean in the Son, he will do the following:
1. Give it a name
2. Select the trigger, (Event)
It will be one of the following:
* Member Created (Triggers when a new member is added)
* Member Deleted (Triggerst when a member is deleted)
* Member Updated (Triggers when a member is updated)
* Page Published (Triggers when a page is published)
* Post Published (Triggers when a post is published)
* Post Scheduled (Triggers when a post is scheduled)
1. Select if it should be inmediate or with a delay. Adding a delay allows users to create email sequences, that can be sent out at different intervals: a welcome email right away, a survey request five days later, and an exclusive offer one month after that.
2. Select the action. (can be multiple)
1. sending a transactional email, using a template from listmonk (we will need to retrieve the templates from the api so the user can see the name from a dropdown)
2. Creating/deleting a subscriber. Adding subscriber to a list
3. Creating a campaign (will dig into this later, but probably we will need to select a template, a list to send it to, a date to send it, and we can probably pass some data to replace in the listmonk template placeholders)
3. Can you create the above application step by step, it doesn't need to be in one go.Certainly! I'll guide you through creating this application step by step. Let's start with the basic structure and gradually build up the functionality.
Step 1: Project Setup
Let's begin by setting up the project structure and choosing a Go web framework. For this application, we'll use the Gin framework, which is lightweight, fast, and has good support for API development.
Project SetupClick to open code
Now, let's create a basic main.go file to set up our Gin server:
main.goClick to open code
This sets up a basic Gin server with a webhook endpoint. We'll expand on this as we add more functionality.
Step 2: Docker Setup
Let's create a Dockerfile and a docker-compose.yml file for our application:
DockerfileClick to open code
Now, let's create a docker-compose.yml file:
docker-compose.ymlClick to open code
This docker-compose file sets up our application, Listmonk, and a PostgreSQL database for Listmonk.
Step 3: Basic Authentication
Let's implement basic authentication for our web UI. We'll create a middleware for this:
middleware/auth.goClick to open code
Now, let's update our main.go to use this middleware:
main.goClick to open code
This sets up a basic structure for our application with authentication for the web UI. In the next steps, we'll implement the core functionality for handling webhooks, managing "Sons" (our Zap-like automations), and interacting with the Listmonk API.
Let me know if you want to proceed with the next steps or if you need any clarification on what we've done so far. CopyRetryAready EditGreat! Let's move on to implementing the core functionality of our application.
Step 4: Implement Son (Automation) Model
First, we'll create a model for our Son (automation) structure. This will define how we store and manage our automations.
models/son.goClick to open code
Step 5: Implement Son Storage
Now, let's create a simple storage mechanism for our Sons. For simplicity, we'll use in-memory storage, but in a production environment, you'd want to use a database.
services/son_storage.goClick to open code
Step 6: Implement Son Handlers
Now, let's create handlers for CRUD operations on Sons.
handlers/son_handlers.goClick to open code
Step 7: Update main.go to include Son routes
Let's update our main.go file to include the new Son routes:
main.goClick to open code
These steps set up the basic structure for managing our Sons (automations). In the next steps, we'll implement the webhook handling logic and the Listmonk API integration to actually perform the actions defined in the Sons.
Would you like to proceed with implementing the webhook handling and Listmonk integration, or do you have any questions about what we've done so far? CopyRetryAcontinue EditCertainly! Let's move on to implementing the webhook handling and Listmonk integration.
Step 8: Implement Webhook Handling
First, we'll create a handler for processing the webhooks from Ghost. We'll need to parse the incoming webhook data and trigger the appropriate Sons based on the event type.
handlers/webhook_handler.goClick to open code
Step 9: Implement Listmonk API Client
Now, let's create a simple Listmonk API client to interact with Listmonk:
services/listmonk_client.goClick to open code
Step 10: Implement Son Executor
Now, let's create a service to execute the actions defined in a Son:
services/son_executor.goClick to open code
Step 11: Update main.go to include the new components
Let's update our main.go file to include the new webhook handler and Listmonk client:
main.goClick to open code
With these changes, we've implemented the core functionality of our Ghost-Listmonk connector. The application can now:
Receive webhooks from Ghost
Process the webhooks and determine the trigger type
Find relevant Sons based on the trigger type
Execute the actions defined in the Sons, including sending transactional emails, managing subscribers, and creating campaigns in Listmonk
The next steps would be to:
Implement error handling and logging throughout the application
Add more comprehensive tests
Implement a user interface for managing Sons
Add more sophisticated scheduling and retry mechanisms for action execution
Implement caching and performance optimizations
Would you like to focus on any of these areas next, or do you have any questions about the implementation so far? CopyRetryALet's continue with the next step EditCertainly! Let's focus on implementing error handling and logging throughout the application. This will make our application more robust and easier to debug.
Step 12: Implement Logging
First, let's create a custom logger that we can use throughout our application:
utils/logger.goClick to open code
Now, let's update our services and handlers to use this logger and implement better error handling.
Step 13: Update Son Storage with Logging and Error Handling
services/son_storage.goClick to open code
Step 14: Update Listmonk Client with Logging and Error Handling
services/listmonk_client.goClick to open code
Step 15: Update Son Executor with Logging and Error Handling
services/son_executor.goClick to open code
Step 16: Update Webhook Handler with Logging and Error Handling
handlers/webhook_handler.goClick to open code CopyRetryClaudes response was limited as it hit the maximum length allowed at this time.Acontinue with step 16 EditCertainly! Let's continue with step 16, updating the Webhook Handler with logging and error handling.
handlers/webhook_handler.goClick to open code
Now, let's add a utility function to pretty print JSON for logging purposes:
utils/pretty_print.goClick to open code
Step 17: Update Son Handler with Logging and Error Handling
handlers/son_handlers.goClick to open code
Step 18: Update main.go to use the new logger
Finally, let's update our main.go file to use the new logger:
main.goClick to open code
With these changes, we've significantly improved our application's error handling and logging. This will make it easier to debug issues and monitor the application's behavior in production.
The next steps could include:
Implementing comprehensive tests for all components
Creating a user interface for managing Sons
Adding more sophisticated scheduling and retry mechanisms for action execution
Implementing caching and performance optimizations
Would you like to focus on any of these areas next, or do you have any other specific improvements in mind? CopyRetryAThere's an error on webhook_handler in this linereturn "", utils.ErrorLogger.NewError("UnknownTriggerType", "Unable to determine trigger type from webhook data") EditI apologize for the error. You're right, and thank you for catching that. The ErrorLogger doesn't have a NewError method. Let's correct this by creating a custom error type and using it in the webhook_handler.go file.
First, let's create a new file for custom errors:
utils/errors.goClick to open code
Now, let's update the webhook_handler.go file to use this new custom error:
handlers/webhook_handler.goClick to open code
This correction ensures that we're creating and returning a proper custom error when we can't determine the trigger type. The utils.NewError function creates a new CustomError with the given code and message.
When using this error in other parts of the code, you can type assert it to access the Code and Message fields if needed:
goCopyif err, ok := err.(*utils.CustomError); ok {
utils.ErrorLogger.Errorf("Custom error occurred: %s - %s", err.Code, err.Message)
} else {
utils.ErrorLogger.Errorf("An error occurred: %v", err)
}
This approach provides more structured error handling and allows you to include additional context (the error code) when logging or responding to errors.

File diff suppressed because it is too large Load diff

50
routes/routes.go Normal file
View file

@ -0,0 +1,50 @@
package routes
import (
"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"
)
func SetupRoutes(r *gin.Engine, handlers *handlers.Handlers, services *services.Services) {
api := r.Group("/api")
{
// Public routes
api.POST("/auth/magic-link", handlers.Auth.RequestMagicLink)
api.GET("/auth/verify", handlers.Auth.VerifyMagicLink)
// Protected routes
protected := api.Group("")
protected.Use(middleware.AuthRequired(services.User))
{
protected.GET("/", handlers.Home.HandleHome)
sons := protected.Group("/sons")
{
sons.POST("", handlers.Son.Create)
sons.GET("", handlers.Son.List)
sons.GET("/:id", handlers.Son.Get)
sons.PUT("/:id", handlers.Son.Update)
sons.DELETE("/:id", handlers.Son.Delete)
}
protected.GET("/son-execution-logs", handlers.SonExecutionLog.GetSonExecutionLogs)
protected.GET("/son-executions/:executionId/action-logs", handlers.SonExecutionLog.GetActionExecutionLogs)
protected.GET("/webhook-info", handlers.Webhook.GetWebhookInfo)
protected.GET("/lists", handlers.Listmonk.GetLists)
protected.GET("/templates", handlers.Listmonk.GetTemplates)
// Webhook log routes
protected.GET("/webhook-logs", handlers.WebhookLog.GetLogs)
protected.GET("/webhook-logs/:id", handlers.WebhookLog.GetLogDetails)
protected.POST("/webhook-logs/:id/replay", handlers.Webhook.ReplayWebhook)
protected.GET("/recent-activity", handlers.RecentActivity.GetRecentActivity)
protected.GET("/son-stats", handlers.SonStats.GetSonStats)
}
}
// Webhook route (public, but requires signature verification)
r.POST("/webhook/:endpoint", handlers.Webhook.HandleWebhook)
}

View file

@ -1,235 +0,0 @@
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
}

98
services/email_service.go Normal file
View file

@ -0,0 +1,98 @@
package services
import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type EmailService struct {
sesClient *ses.SES
}
func NewEmailService() (*EmailService, error) {
config := utils.GetConfig()
awsConfig := &aws.Config{
Region: aws.String(config.AWSRegion),
}
// Only set static credentials if both access key and secret key are provided
if config.AWSAccessKey != "" && config.AWSSecretKey != "" {
utils.InfoLogger.Printf("Using AWS credentials: %s, %s", config.AWSAccessKey, config.AWSSecretKey)
awsConfig.Credentials = credentials.NewStaticCredentials(
config.AWSAccessKey,
config.AWSSecretKey,
"", // token can be left empty for non-temporary credentials
)
} else {
utils.InfoLogger.Println("Using default AWS credentials")
}
sess, err := session.NewSession(awsConfig)
if err != nil {
return nil, err
}
return &EmailService{
sesClient: ses.New(sess),
}, nil
}
func (s *EmailService) SendMagicLinkEmail(to, magicLink string) error {
input := &ses.SendEmailInput{
Destination: &ses.Destination{
ToAddresses: []*string{aws.String(to)},
},
Message: &ses.Message{
Body: &ses.Body{
Html: &ses.Content{
Data: aws.String(generateHTMLEmail(magicLink)),
},
Text: &ses.Content{
Data: aws.String("Your magic link: " + magicLink),
},
},
Subject: &ses.Content{
Data: aws.String("Your Magic Link"),
},
},
Source: aws.String(utils.GetConfig().SESFromEmail),
}
_, err := s.sesClient.SendEmail(input)
if err != nil {
utils.ErrorLogger.Printf("Failed to send email: %v", err)
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case ses.ErrCodeMessageRejected:
utils.ErrorLogger.Printf("Message rejected: %v", aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException:
utils.ErrorLogger.Printf("Mail from domain not verified: %v", aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException:
utils.ErrorLogger.Printf("Configuration set does not exist: %v", aerr.Error())
default:
utils.ErrorLogger.Printf("Unknown error: %v", aerr.Error())
}
} else {
utils.ErrorLogger.Printf("Unknown error: %v", err.Error())
}
}
return err
}
func generateHTMLEmail(magicLink string) string {
return `
<html>
<body>
<h1>Your Magic Link</h1>
<p>Click the button below to log in:</p>
<a href="` + magicLink + `" style="background-color: #4CAF50; border: none; color: white; padding: 15px 32px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; margin: 4px 2px; cursor: pointer;">Log In</a>
</body>
</html>
`
}

View file

@ -76,11 +76,12 @@ func (c *ListmonkClient) GetTemplates() ([]ListmonkTemplate, error) {
return result.Data, nil
}
func (c *ListmonkClient) SendTransactionalEmail(templateID int, subscriberEmail string, data map[string]interface{}) error {
func (c *ListmonkClient) SendTransactionalEmail(templateID int, subscriberEmail string, data map[string]interface{}, headers []map[string]string) error {
payload := map[string]interface{}{
"subscriber_email": subscriberEmail,
"template_id": templateID,
"data": data,
"headers": headers,
}
jsonPayload, err := json.Marshal(payload)
@ -106,12 +107,13 @@ func (c *ListmonkClient) SendTransactionalEmail(templateID int, subscriberEmail
return nil
}
func (c *ListmonkClient) ManageSubscriber(email string, name string, status string, lists []int) error {
func (c *ListmonkClient) ManageSubscriber(email string, name string, status string, lists []int, attributes map[string]interface{}) error {
payload := map[string]interface{}{
"email": email,
"name": name,
"status": status,
"lists": lists,
"attribs": attributes,
"preconfirm_subscriptions": true,
}
@ -121,7 +123,6 @@ func (c *ListmonkClient) ManageSubscriber(email string, name string, status stri
return fmt.Errorf("failed to marshal payload: %w", err)
}
// log the payload
utils.InfoLogger.Infof("Payload: %s", string(jsonPayload))
resp, err := c.client.Post(c.baseURL+"/api/subscribers", "application/json", bytes.NewBuffer(jsonPayload))
@ -137,38 +138,85 @@ func (c *ListmonkClient) ManageSubscriber(email string, name string, status stri
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
utils.InfoLogger.Infof("Managed subscriber %s with status %s", email, status)
utils.InfoLogger.Infof("Managed subscriber %s with status %s and attributes %v", email, status, attributes)
return nil
}
func (c *ListmonkClient) CreateCampaign(name string, subject string, lists []int, templateID int, sendAt string) error {
func (c *ListmonkClient) CreateCampaign(name string, subject string, lists []int, templateID int, sendAt string, body string, contentType string) (int, error) {
payload := map[string]interface{}{
"name": name,
"subject": subject,
"lists": lists,
"template_id": templateID,
"send_at": sendAt,
"name": name,
"subject": subject,
"lists": lists,
"template_id": templateID,
"send_at": sendAt,
"content_type": contentType,
"body": body,
}
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)
return 0, 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)
return 0, fmt.Errorf("failed to create campaign: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(respBody))
return 0, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(respBody))
}
var result struct {
Data struct {
ID int `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return 0, fmt.Errorf("failed to unmarshal response: %w", err)
}
utils.InfoLogger.Infof("Created campaign %s with subject %s, ID: %d", name, subject, result.Data.ID)
return result.Data.ID, nil
}
// In services/listmonk_client.go
func (c *ListmonkClient) UpdateCampaignStatus(id int, status string) error {
payload := map[string]string{"status": status}
jsonPayload, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
url := fmt.Sprintf("%s/api/campaigns/%d/status", c.baseURL, id)
resp, err := c.put(url, jsonPayload)
if err != nil {
return fmt.Errorf("failed to update campaign status: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.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)
utils.InfoLogger.Infof("Updated campaign %d status to %s", id, status)
return nil
}
// Helper method for PUT requests
func (c *ListmonkClient) put(url string, body []byte) (*http.Response, error) {
req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return c.client.Do(req)
}

View file

@ -1,47 +0,0 @@
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)
}

View file

@ -0,0 +1,51 @@
package services
import (
"database/sql"
"time"
"github.com/troneras/ghost-listmonk-connector/database"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type MagicLinkService struct {
db *sql.DB
}
func NewMagicLinkService() *MagicLinkService {
return &MagicLinkService{db: database.GetDB()}
}
func (s *MagicLinkService) CreateToken(userID string) (string, error) {
token := utils.GenerateUUID()
expiresAt := time.Now().Add(15 * time.Minute)
_, err := s.db.Exec("INSERT INTO magic_links (token, user_id, expires_at) VALUES (?, ?, ?)", token, userID, expiresAt)
if err != nil {
return "", err
}
return token, nil
}
func (s *MagicLinkService) VerifyToken(token string) (string, error) {
var userID string
var expiresAt time.Time
err := s.db.QueryRow("SELECT user_id, expires_at FROM magic_links WHERE token = ?", token).Scan(&userID, &expiresAt)
if err != nil {
return "", err
}
if time.Now().After(expiresAt) {
return "", utils.NewError("TokenExpired", "Token has expired")
}
// Delete the used token
_, err = s.db.Exec("DELETE FROM magic_links WHERE token = ?", token)
if err != nil {
return "", err
}
return userID, nil
}

View file

@ -0,0 +1,53 @@
// services/recent_activity_service.go
package services
import (
"database/sql"
"github.com/troneras/ghost-listmonk-connector/database"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type RecentActivityService struct {
db *sql.DB
}
func NewRecentActivityService() *RecentActivityService {
return &RecentActivityService{db: database.GetDB()}
}
func (s *RecentActivityService) LogActivity(userID, actionType, description string) error {
id := utils.GenerateUUID()
_, err := s.db.Exec(`
INSERT INTO recent_activity (id, user_id, action_type, description)
VALUES (?, ?, ?, ?)
`, id, userID, actionType, description)
return err
}
func (s *RecentActivityService) GetRecentActivity(userID string, limit int) ([]models.RecentActivity, error) {
rows, err := s.db.Query(`
SELECT id, user_id, action_type, description, timestamp
FROM recent_activity
WHERE user_id = ?
ORDER BY timestamp DESC
LIMIT ?
`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var activities []models.RecentActivity
for rows.Next() {
var activity models.RecentActivity
err := rows.Scan(&activity.ID, &activity.UserID, &activity.ActionType, &activity.Description, &activity.Timestamp)
if err != nil {
return nil, err
}
activities = append(activities, activity)
}
return activities, nil
}

51
services/services.go Normal file
View file

@ -0,0 +1,51 @@
package services
import (
"github.com/troneras/ghost-listmonk-connector/utils"
)
type Services struct {
User *UserService
MagicLink *MagicLinkService
Email *EmailService
SonStorage *SonStorage
SonExecutor *SonExecutor
Webhook *WebhookService
ListmonkClient *ListmonkClient
WebhookLogger *WebhookLogger
SonExecutionLogger *SonExecutionLogger
RecentActivity *RecentActivityService
}
func NewServices(config *utils.Config) (*Services, error) {
emailService, err := NewEmailService()
if err != nil {
return nil, err
}
webhookService := NewWebhookService()
userService := NewUserService(webhookService)
listmonkClient := NewListmonkClient(config)
sonExecutionLogger := NewSonExecutionLogger(config.RedisAddr)
recentActivity := NewRecentActivityService()
sonExecutor, err := NewSonExecutor(listmonkClient, config.RedisAddr, sonExecutionLogger)
if err != nil {
return nil, err
}
return &Services{
User: userService,
MagicLink: NewMagicLinkService(),
Email: emailService,
SonStorage: NewSonStorage(recentActivity),
SonExecutor: sonExecutor,
Webhook: webhookService,
ListmonkClient: listmonkClient,
WebhookLogger: NewWebhookLogger(),
SonExecutionLogger: sonExecutionLogger,
RecentActivity: recentActivity,
}, nil
}

View file

@ -0,0 +1,187 @@
// services/son_execution_logger.go
package services
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/troneras/ghost-listmonk-connector/database"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type SonExecutionLogger struct {
db *sql.DB
redis *redis.Client
}
func NewSonExecutionLogger(redisAddr string) *SonExecutionLogger {
return &SonExecutionLogger{
db: database.GetDB(),
redis: redis.NewClient(&redis.Options{Addr: redisAddr}),
}
}
type SonStats struct {
Name string `json:"name"`
Executions int `json:"executions"`
Success int `json:"success"`
Failure int `json:"failure"`
}
func (l *SonExecutionLogger) LogSonExecution(sonID, webhookLogID string, status string, errorMessage string) (string, error) {
executionID := utils.GenerateUUID()
_, err := l.db.Exec(`
INSERT INTO son_execution_logs (id, son_id, webhook_log_id, execution_status, error_message)
VALUES (?, ?, ?, ?, ?)
`, executionID, sonID, webhookLogID, status, errorMessage)
if err != nil {
return "", err
}
return executionID, nil
}
func (l *SonExecutionLogger) LogActionExecution(executionID string, actionType string, status string, errorMessage string) error {
_, err := l.db.Exec(`
INSERT INTO son_execution_action_logs (id, son_execution_log_id, action_type, action_status, error_message)
VALUES (?, ?, ?, ?, ?)
`, utils.GenerateUUID(), executionID, actionType, status, errorMessage)
if status == "failure" {
l.UpdateSonExecutionStatus(executionID, "failure", errorMessage)
}
return err
}
func (l *SonExecutionLogger) GetSonExecutionLogs(userID string, limit, offset int) ([]models.SonExecutionLog, int, error) {
var total int
err := l.db.QueryRow(`
SELECT COUNT(*)
FROM son_execution_logs sel
JOIN sons s ON sel.son_id = s.id
WHERE s.user_id = ?
`, userID).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := l.db.Query(`
SELECT sel.id, sel.son_id, sel.webhook_log_id, sel.execution_status, sel.executed_at, sel.error_message
FROM son_execution_logs sel
JOIN sons s ON sel.son_id = s.id
WHERE s.user_id = ?
ORDER BY sel.executed_at DESC
LIMIT ? OFFSET ?
`, userID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var logs []models.SonExecutionLog
for rows.Next() {
var log models.SonExecutionLog
err := rows.Scan(&log.ID, &log.SonID, &log.WebhookLogID, &log.Status, &log.ExecutedAt, &log.ErrorMessage)
if err != nil {
return nil, 0, err
}
logs = append(logs, log)
}
return logs, total, nil
}
func (l *SonExecutionLogger) GetActionExecutionLogs(executionID string) ([]models.ActionExecutionLog, error) {
rows, err := l.db.Query(`
SELECT id, son_execution_log_id, action_type, action_status, executed_at, error_message
FROM son_execution_action_logs
WHERE son_execution_log_id = ?
ORDER BY executed_at ASC
`, executionID)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []models.ActionExecutionLog
for rows.Next() {
var log models.ActionExecutionLog
err := rows.Scan(&log.ID, &log.ExecutionLogID, &log.ActionType, &log.Status, &log.ExecutedAt, &log.ErrorMessage)
if err != nil {
return nil, err
}
logs = append(logs, log)
}
return logs, nil
}
func (l *SonExecutionLogger) UpdateSonExecutionStatus(executionID string, status string, errorMessage string) error {
_, err := l.db.Exec(`
UPDATE son_execution_logs
SET execution_status = ?, error_message = ?
WHERE id = ?
`, status, errorMessage, executionID)
return err
}
func (l *SonExecutionLogger) GetSonStats(ctx context.Context, userID string, timeframe string) ([]SonStats, error) {
cacheKey := fmt.Sprintf("son_stats:%s:%s", userID, timeframe)
// Try to get from cache
cachedStats, err := l.redis.Get(ctx, cacheKey).Result()
if err == nil {
var stats []SonStats
err = json.Unmarshal([]byte(cachedStats), &stats)
if err == nil {
return stats, nil
}
}
// If not in cache or error, fetch from database
duration, err := utils.ParseDuration(timeframe)
if err != nil {
return nil, err
}
query := `
SELECT s.id, s.name,
COUNT(*) as executions,
SUM(CASE WHEN sel.execution_status = 'success' THEN 1 ELSE 0 END) as success,
SUM(CASE WHEN sel.execution_status = 'failure' THEN 1 ELSE 0 END) as failure
FROM sons s
LEFT JOIN son_execution_logs sel ON s.id = sel.son_id
WHERE s.user_id = ? AND sel.executed_at >= ?
GROUP BY s.id, s.name
`
rows, err := l.db.Query(query, userID, time.Now().Add(-duration))
if err != nil {
return nil, err
}
defer rows.Close()
var stats []SonStats
for rows.Next() {
var stat SonStats
var sonID string
err := rows.Scan(&sonID, &stat.Name, &stat.Executions, &stat.Success, &stat.Failure)
if err != nil {
return nil, err
}
stats = append(stats, stat)
}
// Cache the result
statsJSON, err := json.Marshal(stats)
if err == nil {
l.redis.Set(ctx, cacheKey, statsJSON, 5*time.Minute)
}
return stats, nil
}

View file

@ -1,100 +1,466 @@
// services/son_executor.go
package services
import (
"context"
"encoding/json"
"fmt"
"html/template"
"time"
"github.com/hibiken/asynq"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/utils"
)
const (
TypeSendTransactionalEmail = "send_transactional_email"
TypeManageSubscriber = "manage_subscriber"
TypeCreateCampaign = "create_campaign"
)
type SonExecutor struct {
listmonkClient *ListmonkClient
listmonkClient *ListmonkClient
asyncClient *asynq.Client
asyncServer *asynq.Server
executionLogger *SonExecutionLogger
}
func NewSonExecutor(listmonkClient *ListmonkClient) *SonExecutor {
func NewSonExecutor(listmonkClient *ListmonkClient, redisAddr string, executionLogger *SonExecutionLogger) (*SonExecutor, error) {
asyncClient := asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr})
asyncServer := asynq.NewServer(
asynq.RedisClientOpt{Addr: redisAddr},
asynq.Config{
Concurrency: 10,
Queues: map[string]int{
"critical": 6,
"default": 3,
"low": 1,
},
},
)
return &SonExecutor{
listmonkClient: listmonkClient,
}
listmonkClient: listmonkClient,
asyncClient: asyncClient,
asyncServer: asyncServer,
executionLogger: executionLogger,
}, nil
}
func (e *SonExecutor) ExecuteSon(son models.Son, data map[string]interface{}) {
utils.InfoLogger.Infof("Executing Son: %s", son.ID)
func (e *SonExecutor) Start() error {
mux := asynq.NewServeMux()
mux.HandleFunc(TypeSendTransactionalEmail, e.handleSendTransactionalEmail)
mux.HandleFunc(TypeManageSubscriber, e.handleManageSubscriber)
mux.HandleFunc(TypeCreateCampaign, e.handleCreateCampaign)
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))
return e.asyncServer.Start(mux)
}
func (e *SonExecutor) Stop() {
e.asyncServer.Shutdown()
e.asyncClient.Close()
}
func (e *SonExecutor) ExecuteSon(son models.Son, data map[string]interface{}, webhookLogID string) {
executionID, err := e.executionLogger.LogSonExecution(son.ID, webhookLogID, "success", "")
if err != nil {
utils.ErrorLogger.Errorf("Failed to log son execution: %v", err)
return
}
for _, action := range son.Actions {
err := e.executeAction(action, data)
payload, err := json.Marshal(map[string]interface{}{
"action": action,
"data": data,
"execution_id": executionID,
})
if err != nil {
utils.ErrorLogger.Errorf("Error executing action for Son %s: %v", son.ID, err)
utils.ErrorLogger.Errorf("Failed to marshal action payload: %v", err)
e.executionLogger.LogActionExecution(executionID, string(action.Type), "failure", err.Error())
continue
}
var task *asynq.Task
switch action.Type {
case models.ActionSendTransactionalEmail:
task = asynq.NewTask(TypeSendTransactionalEmail, payload)
case models.ActionManageSubscriber:
task = asynq.NewTask(TypeManageSubscriber, payload)
case models.ActionCreateCampaign:
task = asynq.NewTask(TypeCreateCampaign, payload)
default:
utils.ErrorLogger.Errorf("Unknown action type: %s", action.Type)
e.executionLogger.LogActionExecution(executionID, string(action.Type), "failure", "Unknown action type")
continue
}
delay, err := son.GetParsedDelay()
if err != nil {
utils.ErrorLogger.Errorf("Failed to parse delay: %v", err)
delay = 0
}
info, err := e.asyncClient.Enqueue(task, asynq.ProcessIn(delay), asynq.MaxRetry(3), asynq.Queue("default"))
if err != nil {
utils.ErrorLogger.Errorf("Failed to enqueue task: %v", err)
e.executionLogger.LogActionExecution(executionID, string(action.Type), "failure", err.Error())
} else {
utils.InfoLogger.Infof("Enqueued task: id=%s queue=%s", info.ID, info.Queue)
e.executionLogger.LogActionExecution(executionID, string(action.Type), "queued", "")
}
}
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)
func (e *SonExecutor) handleSendTransactionalEmail(ctx context.Context, t *asynq.Task) error {
var payload map[string]interface{}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("failed to unmarshal payload: %v", err)
}
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)
executionID, ok := payload["execution_id"].(string)
if !ok {
return fmt.Errorf("invalid execution_id in payload")
}
action, ok := payload["action"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid action in payload")
}
params, ok := action["parameters"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid parameters in action")
}
data, ok := payload["data"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid data in payload")
}
err := e.sendTransactionalEmail(params, data)
if err != nil {
e.executionLogger.LogActionExecution(executionID, "send_transactional_email", "failure", err.Error())
return err
}
e.executionLogger.LogActionExecution(executionID, "send_transactional_email", "success", "")
return nil
}
func (e *SonExecutor) handleManageSubscriber(ctx context.Context, t *asynq.Task) error {
var payload map[string]interface{}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("failed to unmarshal payload: %v", err)
}
executionID, ok := payload["execution_id"].(string)
if !ok {
return fmt.Errorf("invalid execution_id in payload")
}
action, ok := payload["action"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid action in payload")
}
params, ok := action["parameters"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid parameters in action")
}
data, ok := payload["data"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid data in payload")
}
err := e.manageSubscriber(params, data)
if err != nil {
e.executionLogger.LogActionExecution(executionID, "manage_subscriber", "failure", err.Error())
return err
}
e.executionLogger.LogActionExecution(executionID, "manage_subscriber", "success", "")
return nil
}
func (e *SonExecutor) handleCreateCampaign(ctx context.Context, t *asynq.Task) error {
var payload map[string]interface{}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("failed to unmarshal payload: %v", err)
}
executionID, ok := payload["execution_id"].(string)
if !ok {
return fmt.Errorf("invalid execution_id in payload")
}
action, ok := payload["action"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid action in payload")
}
params, ok := action["parameters"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid parameters in action")
}
data, ok := payload["data"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid data in payload")
}
// Parse the template
body, ok := params["body"].(string)
if !ok {
return fmt.Errorf("invalid body in parameters")
}
postData := map[string]interface{}{
"Title": data["post"].(map[string]interface{})["current"].(map[string]interface{})["title"],
"FeatureImage": data["post"].(map[string]interface{})["current"].(map[string]interface{})["feature_image"],
"Slug": data["post"].(map[string]interface{})["current"].(map[string]interface{})["slug"],
"CustomExcerpt": data["post"].(map[string]interface{})["current"].(map[string]interface{})["custom_excerpt"],
"Html": template.HTML(data["post"].(map[string]interface{})["current"].(map[string]interface{})["html"].(string)),
"PlainText": data["post"].(map[string]interface{})["current"].(map[string]interface{})["plaintext"],
"PublishedAt": data["post"].(map[string]interface{})["current"].(map[string]interface{})["published_at"],
}
parsedBody, err := utils.ParseTemplate(body, postData)
if err != nil {
e.executionLogger.LogActionExecution(executionID, "create_campaign", "failure", fmt.Sprintf("Failed to parse template: %v", err))
return err
}
// Update the params with the parsed body
params["body"] = parsedBody
campaignID, err := e.createCampaign(params, data)
if err != nil {
e.executionLogger.LogActionExecution(executionID, "create_campaign", "failure", err.Error())
return err
}
// Update the campaign status to 'scheduled'
err = e.listmonkClient.UpdateCampaignStatus(campaignID, "scheduled")
if err != nil {
e.executionLogger.LogActionExecution(executionID, "update_campaign_status", "failure", err.Error())
return err
}
e.executionLogger.LogActionExecution(executionID, "create_campaign", "success", "")
return nil
}
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)
templateID, ok := params["template_id"].(float64)
if !ok {
return fmt.Errorf("invalid or missing template_id")
}
utils.InfoLogger.Infof("Sending transactional email to %s using template %d", subscriberEmail, templateID)
return e.listmonkClient.SendTransactionalEmail(templateID, subscriberEmail, data)
subscriberEmail, err := getSubscriberEmail(data)
if err != nil {
return err
}
headers, err := getHeaders(params)
if err != nil {
return err
}
additionalData, err := getAdditionalData(params)
if err != nil {
return err
}
mergedData := mergeData(data, additionalData)
utils.InfoLogger.Infof("Sending transactional email to %s using template %d", subscriberEmail, int(templateID))
return e.listmonkClient.SendTransactionalEmail(int(templateID), subscriberEmail, mergedData, headers)
}
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)
member, ok := data["member"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid member data")
}
current, ok := member["current"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid current member data")
}
email, _ := current["email"].(string)
name, _ := current["name"].(string)
utils.InfoLogger.Infof("Managing subscriber %s", email)
utils.InfoLogger.Infof("Params: %v", params)
var name string
if member, ok := data["member"].(map[string]interface{}); ok {
if current, ok := member["current"].(map[string]interface{}); ok {
if n, ok := current["name"].(string); ok {
name = n
status := "enabled" // params["status"].(string)
lists := []int{}
if listsParam, ok := params["lists"]; ok && listsParam != nil {
if listSlice, ok := listsParam.([]interface{}); ok {
for _, listID := range listSlice {
if id, ok := listID.(float64); ok {
lists = append(lists, int(id))
}
}
}
}
status := "enabled" //params["status"].(string)
lists := []int{}
for _, listID := range params["lists"].([]interface{}) {
lists = append(lists, int(listID.(float64)))
var geoLocation map[string]interface{}
if geoStr, ok := current["geolocation"].(string); ok {
err := json.Unmarshal([]byte(geoStr), &geoLocation)
if err != nil {
utils.ErrorLogger.Errorf("Error parsing geolocation data: %v", err)
}
}
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)))
attributes := make(map[string]interface{})
if geoLocation != nil {
attributes["city"] = geoLocation["city"]
attributes["country"] = geoLocation["country"]
attributes["latitude"] = geoLocation["latitude"]
attributes["longitude"] = geoLocation["longitude"]
attributes["timezone"] = geoLocation["timezone"]
}
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)
utils.InfoLogger.Infof("Managing subscriber %s with status %s, lists %v, and attributes %v", email, status, lists, attributes)
return e.listmonkClient.ManageSubscriber(email, name, status, lists, attributes)
}
func (e *SonExecutor) createCampaign(params map[string]interface{}, data map[string]interface{}) (int, error) {
// Add error checking for each parameter
name, ok := params["name"].(string)
if !ok {
return 0, fmt.Errorf("invalid or missing name parameter")
}
// Append a timestamp and random string to ensure uniqueness
uniqueSuffix := fmt.Sprintf("_%s_%s", time.Now().Format("20060102_150405"), utils.GenerateRandomString(5))
uniqueName := name + uniqueSuffix
subject, ok := params["subject"].(string)
if !ok {
return 0, fmt.Errorf("invalid or missing subject parameter")
}
lists, ok := params["lists"].([]interface{})
if !ok {
return 0, fmt.Errorf("invalid or missing lists parameter")
}
templateID, ok := params["template_id"].(float64)
if !ok {
return 0, fmt.Errorf("invalid or missing template_id parameter")
}
sendAt, ok := params["send_at"].(string)
if !ok || sendAt == "" {
// If send_at is not provided or is empty, set it to 5 minutes from now
sendAt = time.Now().UTC().Add(5 * time.Minute).Format(time.RFC3339)
} else {
// If send_at is provided, ensure it's in the future
scheduledTime, err := time.Parse(time.RFC3339, sendAt)
if err != nil {
return 0, fmt.Errorf("invalid send_at time format: %v", err)
}
if scheduledTime.Before(time.Now().UTC()) {
// If the provided time is in the past, set it to 5 minutes from now
sendAt = time.Now().UTC().Add(5 * time.Minute).Format(time.RFC3339)
}
}
utils.InfoLogger.Infof("Scheduling campaign %s for %s", uniqueName, sendAt)
body, ok := params["body"].(string)
if !ok {
return 0, fmt.Errorf("invalid or missing body parameter")
}
contentType, ok := params["content_type"].(string)
if !ok {
contentType = "html" // Default to HTML if not provided
}
// Convert lists to []int
listIDs := make([]int, len(lists))
for i, v := range lists {
listID, ok := v.(float64)
if !ok {
return 0, fmt.Errorf("invalid list ID at index %d", i)
}
listIDs[i] = int(listID)
}
utils.InfoLogger.Infof("Creating campaign %s with subject %s", uniqueName, subject)
return e.listmonkClient.CreateCampaign(uniqueName, subject, listIDs, int(templateID), sendAt, body, contentType)
}
func getSubscriberEmail(data map[string]interface{}) (string, error) {
member, ok := data["member"].(map[string]interface{})
if !ok {
return "", fmt.Errorf("invalid member data")
}
current, ok := member["current"].(map[string]interface{})
if !ok {
return "", fmt.Errorf("invalid current member data")
}
email, ok := current["email"].(string)
if !ok {
return "", fmt.Errorf("invalid or missing email")
}
return email, nil
}
func getHeaders(params map[string]interface{}) ([]map[string]string, error) {
headersRaw, ok := params["headers"]
if !ok {
return nil, nil // Headers are optional
}
headersJSON, err := json.Marshal(headersRaw)
if err != nil {
return nil, fmt.Errorf("error marshalling headers: %v", err)
}
var headers []map[string]string
if err := json.Unmarshal(headersJSON, &headers); err != nil {
return nil, fmt.Errorf("error unmarshalling headers: %v", err)
}
return headers, nil
}
func getAdditionalData(params map[string]interface{}) (map[string]interface{}, error) {
additionalDataRaw, ok := params["data"]
if !ok {
return nil, nil // Additional data is optional
}
additionalDataJSON, err := json.Marshal(additionalDataRaw)
if err != nil {
return nil, fmt.Errorf("error marshalling additional data: %v", err)
}
var additionalData map[string]interface{}
if err := json.Unmarshal(additionalDataJSON, &additionalData); err != nil {
return nil, fmt.Errorf("error unmarshalling additional data: %v", err)
}
return additionalData, nil
}
func mergeData(data1, data2 map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
for k, v := range data1 {
result[k] = v
}
for k, v := range data2 {
result[k] = v
}
return result
}

View file

@ -4,9 +4,9 @@ import (
"database/sql"
"encoding/json"
"errors"
"time"
"fmt"
_ "github.com/mattn/go-sqlite3"
"github.com/troneras/ghost-listmonk-connector/database"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/utils"
)
@ -17,61 +17,38 @@ var (
)
type SonStorage struct {
db *sql.DB
db *sql.DB
recentActivityService *RecentActivityService
}
func NewSonStorage(dbPath string) (*SonStorage, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
// Update the NewSonStorage function
func NewSonStorage(recentActivityService *RecentActivityService) *SonStorage {
return &SonStorage{
db: database.GetDB(),
recentActivityService: recentActivityService,
}
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 {
utils.ErrorLogger.Errorf("Failed to marshal actions: %v", err)
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,
"INSERT INTO sons (id, user_id, name, trigger_event, delay, actions, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())",
son.ID, son.UserID, son.Name, son.Trigger, son.Delay, actionsJSON, son.Enabled,
)
if err != nil {
utils.ErrorLogger.Errorf("Failed to create Son: %v", err)
return err
}
if err := s.recentActivityService.LogActivity(son.UserID, "son_created", fmt.Sprintf("Created Son: %s", son.Name)); err != nil {
utils.ErrorLogger.Printf("Failed to log activity: %v", err)
}
utils.InfoLogger.Infof("Created new Son with ID: %s", son.ID)
return nil
}
@ -79,12 +56,11 @@ func (s *SonStorage) Create(son *models.Son) error {
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 = ?",
"SELECT id, user_id, name, trigger_event, delay, actions, enabled, created_at, updated_at FROM sons WHERE id = ?",
id,
).Scan(&son.ID, &son.Name, &son.Trigger, &delayInt, &actionsJSON, &son.CreatedAt, &son.UpdatedAt)
).Scan(&son.ID, &son.UserID, &son.Name, &son.Trigger, &son.Delay, &actionsJSON, &son.Enabled, &son.CreatedAt, &son.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
@ -95,8 +71,6 @@ func (s *SonStorage) Get(id string) (models.Son, error) {
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)
@ -108,16 +82,14 @@ func (s *SonStorage) Get(id string) (models.Son, error) {
}
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,
"UPDATE sons SET name = ?, trigger_event = ?, delay = ?, actions = ?, enabled = ?, updated_at = NOW() WHERE id = ? AND user_id = ?",
son.Name, son.Trigger, son.Delay, actionsJSON, son.Enabled, son.ID, son.UserID,
)
if err != nil {
utils.ErrorLogger.Errorf("Failed to update Son: %v", err)
@ -134,12 +106,16 @@ func (s *SonStorage) Update(son models.Son) error {
return ErrSonNotFound
}
if err := s.recentActivityService.LogActivity(son.UserID, "son_updated", fmt.Sprintf("Updated Son: %s", son.Name)); err != nil {
utils.ErrorLogger.Printf("Failed to log activity: %v", err)
}
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)
func (s *SonStorage) Delete(id string, userID string) error {
result, err := s.db.Exec("DELETE FROM sons WHERE id = ? AND user_id = ?", id, userID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to delete Son: %v", err)
return err
@ -155,12 +131,16 @@ func (s *SonStorage) Delete(id string) error {
return ErrSonNotFound
}
if err := s.recentActivityService.LogActivity(userID, "son_deleted", fmt.Sprintf("Deleted Son: %s", id)); err != nil {
utils.ErrorLogger.Printf("Failed to log activity: %v", err)
}
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")
func (s *SonStorage) List(userID string) ([]models.Son, error) {
rows, err := s.db.Query("SELECT id, user_id, name, trigger_event, delay, actions, enabled, created_at, updated_at FROM sons WHERE user_id = ?", userID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
return nil, err
@ -171,16 +151,13 @@ func (s *SonStorage) List() ([]models.Son, error) {
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)
err := rows.Scan(&son.ID, &son.UserID, &son.Name, &son.Trigger, &son.Delay, &actionsJSON, &son.Enabled, &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)
@ -191,9 +168,9 @@ func (s *SonStorage) List() ([]models.Son, error) {
}
if sons == nil {
sons = []models.Son{} // Ensure we always return an array, even if empty
sons = []models.Son{}
}
utils.InfoLogger.Infof("Retrieved list of %d Sons", len(sons))
utils.InfoLogger.Infof("Retrieved list of %d Sons for user %s", len(sons), userID)
return sons, nil
}

View file

@ -1,82 +0,0 @@
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)
}

92
services/user_service.go Normal file
View file

@ -0,0 +1,92 @@
package services
import (
"database/sql"
"time"
"github.com/troneras/ghost-listmonk-connector/database"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type UserService struct {
db *sql.DB
webhookService *WebhookService
}
func NewUserService(webhookService *WebhookService) *UserService {
return &UserService{
db: database.GetDB(),
webhookService: webhookService,
}
}
func (s *UserService) GetUserByEmail(email string) (*models.User, error) {
var user models.User
err := s.db.QueryRow("SELECT id, email, role, subscription_level, created_at, updated_at FROM users WHERE email = ?", email).
Scan(&user.ID, &user.Email, &user.Role, &user.SubscriptionLevel, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) GetUserByID(id string) (*models.User, error) {
var user models.User
err := s.db.QueryRow("SELECT id, email, role, subscription_level, created_at, updated_at FROM users WHERE id = ?", id).
Scan(&user.ID, &user.Email, &user.Role, &user.SubscriptionLevel, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) CreateUser(email string) (*models.User, error) {
tx, err := s.db.Begin()
if err != nil {
utils.ErrorLogger.Printf("Failed to start transaction: %v", err)
return nil, err
}
defer tx.Rollback()
id := utils.GenerateUUID()
now := time.Now()
_, err = tx.Exec("INSERT INTO users (id, email, role, subscription_level, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
id, email, models.RoleUser, models.SubscriptionFree, now, now)
if err != nil {
utils.ErrorLogger.Printf("Failed to create user: %v", err)
return nil, err
}
// Commit the transaction to create the user
if err = tx.Commit(); err != nil {
utils.ErrorLogger.Printf("Failed to commit transaction: %v", err)
return nil, err
}
user := &models.User{
ID: id,
Email: email,
Role: models.RoleUser,
SubscriptionLevel: models.SubscriptionFree,
CreatedAt: now,
UpdatedAt: now,
}
// Create default webhook in a separate operation
_, err = s.webhookService.CreateWebhook(id)
if err != nil {
utils.ErrorLogger.Printf("Failed to create default webhook: %v", err)
// Note: We don't return here because the user has been created successfully
// You may want to implement a cleanup or retry mechanism for the webhook creation
}
return user, nil
}
func (s *UserService) UpdateUser(user *models.User) error {
_, err := s.db.Exec("UPDATE users SET email = ?, role = ?, subscription_level = ?, updated_at = ? WHERE id = ?",
user.Email, user.Role, user.SubscriptionLevel, time.Now(), user.ID)
return err
}

View file

@ -0,0 +1,156 @@
package services
import (
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/troneras/ghost-listmonk-connector/database"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type WebhookLogger struct {
db *sql.DB
}
func NewWebhookLogger() *WebhookLogger {
return &WebhookLogger{db: database.GetDB()}
}
func (l *WebhookLogger) CreateWebhookLog(userID string, req *http.Request, body []byte) (string, error) {
// Convert headers to JSON
headerMap := make(map[string]string)
for k, v := range req.Header {
headerMap[k] = v[0]
}
headersJSON, err := json.Marshal(headerMap)
if err != nil {
return "", err
}
logID := utils.GenerateUUID()
// Insert initial log into database
_, err = l.db.Exec(`
INSERT INTO webhook_logs (id, user_id, timestamp, method, path, headers, body, status_code, duration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, logID, userID, time.Now(), req.Method, req.URL.Path, string(headersJSON), string(body), 200, 0)
if err != nil {
utils.ErrorLogger.Errorf("Failed to insert initial webhook log: %v", err)
return "", err
}
return logID, nil
}
func (l *WebhookLogger) UpdateWebhookLog(logID string, statusCode int, response interface{}, duration time.Duration) error {
// Convert response to JSON
responseJSON, err := json.Marshal(response)
if err != nil {
return err
}
// Update log in database
_, err = l.db.Exec(`
UPDATE webhook_logs
SET status_code = ?, response_body = ?, duration = ?
WHERE id = ?
`, statusCode, string(responseJSON), int(duration.Milliseconds()), logID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to update webhook log: %v", err)
return err
}
return nil
}
func (l *WebhookLogger) GetWebhookLogs(userID string, limit, offset int) ([]WebhookLog, int, error) {
// First, get the total count of logs for this user
var total int
err := l.db.QueryRow("SELECT COUNT(*) FROM webhook_logs WHERE user_id = ?", userID).Scan(&total)
if err != nil {
utils.ErrorLogger.Printf("Failed to get total log count: %v", err)
return nil, 0, err
}
// Now, get the paginated logs
rows, err := l.db.Query(`
SELECT id, timestamp, method, path, status_code, duration
FROM webhook_logs
WHERE user_id = ?
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
`, userID, limit, offset)
if err != nil {
utils.ErrorLogger.Printf("Failed to query webhook logs: %v", err)
return nil, 0, err
}
defer rows.Close()
var logs []WebhookLog
for rows.Next() {
var log WebhookLog
err := rows.Scan(&log.ID, &log.Timestamp, &log.Method, &log.Path, &log.StatusCode, &log.Duration)
if err != nil {
utils.ErrorLogger.Printf("Failed to scan webhook log: %v", err)
return nil, 0, err
}
logs = append(logs, log)
}
if err = rows.Err(); err != nil {
utils.ErrorLogger.Printf("Error iterating over webhook logs: %v", err)
return nil, 0, err
}
utils.InfoLogger.Printf("Retrieved %d webhook logs for user %s (total: %d)", len(logs), userID, total)
return logs, total, nil
}
func (l *WebhookLogger) GetWebhookLogDetails(id string) (*WebhookLogDetails, error) {
var log WebhookLogDetails
err := l.db.QueryRow(`
SELECT id, user_id, timestamp, method, path, headers, body, status_code, response_body, duration
FROM webhook_logs
WHERE id = ?
`, id).Scan(&log.ID, &log.UserID, &log.Timestamp, &log.Method, &log.Path, &log.Headers, &log.Body, &log.StatusCode, &log.ResponseBody, &log.Duration)
if err != nil {
return nil, err
}
return &log, nil
}
func (l *WebhookLogger) GetWebhookLogForReplay(id string) (*WebhookLogDetails, error) {
var log WebhookLogDetails
err := l.db.QueryRow(`
SELECT id, user_id, timestamp, method, path, headers, body, status_code, response_body, duration
FROM webhook_logs
WHERE id = ?
`, id).Scan(&log.ID, &log.UserID, &log.Timestamp, &log.Method, &log.Path, &log.Headers, &log.Body, &log.StatusCode, &log.ResponseBody, &log.Duration)
if err != nil {
return nil, err
}
return &log, nil
}
type WebhookLog struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Path string `json:"path"`
StatusCode int `json:"status_code"`
Duration int `json:"duration"`
}
type WebhookLogDetails struct {
WebhookLog
Headers string `json:"headers"`
Body string `json:"body"`
ResponseBody string `json:"response_body"`
}

View file

@ -0,0 +1,72 @@
package services
import (
"database/sql"
"time"
"github.com/troneras/ghost-listmonk-connector/database"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type WebhookService struct {
db *sql.DB
}
func NewWebhookService() *WebhookService {
return &WebhookService{db: database.GetDB()}
}
func (s *WebhookService) CreateWebhook(userID string) (*models.Webhook, error) {
id := utils.GenerateUUID()
endpoint := id
secret := utils.GenerateSecret()
now := time.Now()
_, err := s.db.Exec("INSERT INTO webhooks (id, user_id, endpoint, secret, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
id, userID, endpoint, secret, now, now)
if err != nil {
utils.ErrorLogger.Printf("Failed to create webhook for user %s: %v", userID, err)
return nil, err
}
utils.InfoLogger.Printf("Created webhook for user %s", userID)
return &models.Webhook{
ID: id,
UserID: userID,
Endpoint: endpoint,
Secret: secret,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (s *WebhookService) GetWebhooksByUserID(userID string) ([]models.Webhook, error) {
rows, err := s.db.Query("SELECT id, user_id, endpoint, secret, created_at, updated_at FROM webhooks WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var webhooks []models.Webhook
for rows.Next() {
var webhook models.Webhook
err := rows.Scan(&webhook.ID, &webhook.UserID, &webhook.Endpoint, &webhook.Secret, &webhook.CreatedAt, &webhook.UpdatedAt)
if err != nil {
return nil, err
}
webhooks = append(webhooks, webhook)
}
return webhooks, nil
}
func (s *WebhookService) GetWebhookByEndpoint(endpoint string) (*models.Webhook, error) {
var webhook models.Webhook
err := s.db.QueryRow("SELECT id, user_id, endpoint, secret, created_at, updated_at FROM webhooks WHERE endpoint = ?", endpoint).
Scan(&webhook.ID, &webhook.UserID, &webhook.Endpoint, &webhook.Secret, &webhook.CreatedAt, &webhook.UpdatedAt)
if err != nil {
return nil, err
}
return &webhook, nil
}

3
start.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
# Start the Next.js server
docker run -d --name listmonk_connector -e MYSQL_ROOT_PASSWORD=password -p 3307:3306 mariadb:latest

View file

@ -16,7 +16,11 @@ bun dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.

View file

@ -1,31 +0,0 @@
import "@/styles/globals.css";
import { Inter as FontSans } from "next/font/google";
import { cn } from "@/lib/utils";
import Layout from "@/components/layout";
import { Toaster } from "@/components/ui/toaster";
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable
)}
>
<Layout>{children}</Layout>
<Toaster />
</body>
</html>
);
}

View file

@ -1,5 +0,0 @@
import Dashboard from "@/components/Dashboard";
export default function HomePage() {
return <Dashboard />;
}

View file

@ -1,3 +0,0 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <div className="max-w-3xl mx-auto">{children}</div>;
}

View file

@ -1,10 +0,0 @@
import SonCreationForm from "@/components/SonCreationForm";
export default function NewSonPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Create New Son</h1>
<SonCreationForm />
</div>
);
}

View file

@ -1,12 +1,12 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"css": "styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},

View file

@ -1,4 +1,5 @@
import React from "react";
import { UseFormReturn } from "react-hook-form";
import {
FormField,
FormItem,
@ -14,13 +15,21 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { CampaignActionFields } from "./CampaignActionFields";
import { ManageSubscriberActionFields } from "./ManageSubscriberActionFields";
import { ActionFormProps } from "@/lib/types";
import { TransactionalEmailActionFields } from "./TransactionalEmailActionFields";
import { ListmonkList, ListmonkTemplate } from "@/lib/types";
interface ActionFormProps {
form: UseFormReturn<any>;
index: number;
remove: (index: number) => void;
lists: ListmonkList[];
templates: ListmonkTemplate[];
}
export function ActionForm({
form,
@ -29,8 +38,8 @@ export function ActionForm({
lists,
templates,
}: ActionFormProps) {
const actionType = form.watch(`actions.${index}.type`);
const trigger = form.watch("trigger");
const actionType: string = form.watch(`actions.${index}.type`);
const trigger: string = form.watch("trigger");
return (
<Card className="border border-gray-200">
@ -55,7 +64,7 @@ export function ActionForm({
<FormItem>
<FormLabel>Action Type</FormLabel>
<Select
onValueChange={(value) => {
onValueChange={(value: string) => {
field.onChange(value);
form.setValue(`actions.${index}.parameters`, {});
}}
@ -78,14 +87,18 @@ export function ActionForm({
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
{actionType === "manage_subscriber" && (
<FormDescription>
Subscriber management automatically adds or removes
subscribers on listmonk and adds to lists based on the trigger
event and newsletters from ghost blog.
<p>
This action will automatically create the subscriber on
listmonk if he doesn't exist already.
</p>
Subscribers will be automatically added or removed to lists
with the same name as the newsletters on Ghost.
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
@ -99,7 +112,7 @@ export function ActionForm({
/>
)}
{actionType === "manage_subscriber" && trigger == "member_created" && (
{actionType === "manage_subscriber" && trigger === "member_created" && (
<ManageSubscriberActionFields
form={form}
index={index}
@ -108,38 +121,10 @@ export function ActionForm({
)}
{actionType === "send_transactional_email" && (
<FormField
control={form.control}
name={`actions.${index}.parameters`}
render={({ field }) => (
<FormItem>
<FormLabel>Parameters</FormLabel>
<FormControl>
<Textarea
{...field}
onChange={(e) => {
try {
const parsedValue = JSON.parse(e.target.value);
field.onChange(parsedValue);
} catch (error) {
field.onChange(e.target.value);
}
}}
value={
typeof field.value === "object"
? JSON.stringify(field.value, null, 2)
: field.value
}
className="font-mono text-sm"
rows={5}
/>
</FormControl>
<FormDescription>
Enter the parameters for the transactional email as JSON.
</FormDescription>
<FormMessage />
</FormItem>
)}
<TransactionalEmailActionFields
form={form}
index={index}
templates={templates}
/>
)}
</CardContent>

View file

@ -0,0 +1,33 @@
import { useAuthContext } from "@/contexts/AuthContext";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { LogIn, LogOut } from "lucide-react";
import { useRouter } from "next/router";
export const AuthButton = () => {
const { user, logout } = useAuthContext();
const router = useRouter();
const handleLogout = async () => {
await logout();
router.push("/login");
};
return (
<>
{user ? (
<Button onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
) : (
<Link href="/login">
<Button>
<LogIn className="mr-2 h-4 w-4" />
Login
</Button>
</Link>
)}
</>
);
};

View file

@ -18,12 +18,58 @@ import {
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { X } from "lucide-react";
import { CampaignActionFieldsProps } from "@/lib/types";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
export function CampaignActionFields({
form,
index,
lists,
templates,
}: CampaignActionFieldsProps) {
const placeholderTemplate = `
<h1>New Blog Post: {{ .Post.Title }}</h1>
<img src="{{ .Post.FeatureImage }}" alt="Feature image for {{ .Post.Title }}">
<h2>{{ .Post.CustomExcerpt }}</h2>
<div>
{{ .Post.Html }}
</div>
<p>Read the full post at: <a href="https://yourblog.com/{{ .Post.Slug }}">{{ .Post.Title }}</a></p>
<p>Published on: {{ .Post.PublishedAt }}</p>
`;
export function CampaignActionFields({ form, index, lists, templates }: CampaignActionFieldsProps) {
return (
<div className="space-y-4">
<FormField
control={form.control}
name={`actions.${index}.parameters.name`}
render={({ field }) => (
<FormItem>
<FormLabel>Campaign Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter campaign name" />
</FormControl>
<FormDescription>
Enter a unique name for this campaign.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`actions.${index}.parameters.subject`}
@ -40,6 +86,133 @@ export function CampaignActionFields({ form, index, lists, templates }: Campaign
</FormItem>
)}
/>
<FormField
control={form.control}
name={`actions.${index}.parameters.type`}
render={({ field }) => (
<FormItem>
<FormLabel>Campaign Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select campaign type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="regular">Regular</SelectItem>
<SelectItem value="optin">Opt-in</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the type of campaign you want to create.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`actions.${index}.parameters.content_type`}
render={({ field }) => (
<FormItem>
<FormLabel>Content Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select content type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="richtext">Rich Text</SelectItem>
<SelectItem value="html">HTML</SelectItem>
<SelectItem value="markdown">Markdown</SelectItem>
<SelectItem value="plain">Plain Text</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the content type for your campaign.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`actions.${index}.parameters.body`}
render={({ field }) => (
<FormItem>
<FormLabel>Campaign Body</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={placeholderTemplate}
rows={15}
/>
</FormControl>
<FormDescription>
<p>
The main content of your campaign. Use the format specified in
the Content Type field.
</p>
<p>Available placeholders:</p>
<ul className="list-disc pl-5 space-y-1">
<li>
<code>{"{{ .Post.Title }}"}</code> - The title of the post
</li>
<li>
<code>{"{{ .Post.FeatureImage }}"}</code> - URL of the feature
image
</li>
<li>
<code>{"{{ .Post.CustomExcerpt }}"}</code> - The custom
excerpt of the post
</li>
<li>
<code>{"{{ .Post.Html }}"}</code> - The full HTML content of
the post
</li>
<li>
<code>{"{{ .Post.Slug }}"}</code> - The URL slug of the post
</li>
<li>
<code>{"{{ .Post.PublishedAt }}"}</code> - The publication
date of the post
</li>
</ul>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
onClick={() =>
form.setValue(
`actions.${index}.parameters.body`,
placeholderTemplate
)
}
>
Insert Template Example
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
Click to insert a template example using all available
placeholders
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormField
control={form.control}
name={`actions.${index}.parameters.lists`}
@ -95,6 +268,7 @@ export function CampaignActionFields({ form, index, lists, templates }: Campaign
</FormItem>
)}
/>
<FormField
control={form.control}
name={`actions.${index}.parameters.template_id`}
@ -128,6 +302,7 @@ export function CampaignActionFields({ form, index, lists, templates }: Campaign
</FormItem>
)}
/>
<FormField
control={form.control}
name={`actions.${index}.parameters.tags`}
@ -144,20 +319,39 @@ export function CampaignActionFields({ form, index, lists, templates }: Campaign
</FormItem>
)}
/>
<FormField
control={form.control}
name={`actions.${index}.parameters.send_now`}
name={`actions.${index}.parameters.send_at`}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Send Now</FormLabel>
<FormDescription>
Toggle to send the campaign immediately upon creation.
</FormDescription>
</div>
<FormItem>
<FormLabel>Schedule Send</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<DatePicker
selected={field.value ? new Date(field.value) : null}
onChange={(date) =>
field.onChange(date ? date.toISOString() : null)
}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
minDate={new Date(Date.now() + 5 * 60 * 1000)}
placeholderText="Select date and time"
className="w-full p-2 border rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
calendarClassName="bg-white border rounded-md shadow-lg"
wrapperClassName="w-full"
popperClassName="z-10"
timeClassName={() => "text-blue-600"}
dayClassName={() => "hover:bg-blue-100"}
monthClassName={() => "text-gray-700"}
weekDayClassName={() => "text-gray-500"}
/>
</FormControl>
<FormDescription>
Schedule when to send this campaign. If not set or set to a time
in the past, the campaign will be scheduled for 5 minutes from
now.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

View file

@ -0,0 +1,17 @@
import { SonProvider } from "@/contexts/SonContext";
import { ListProvider } from "@/contexts/ListContext";
import { TemplateProvider } from "@/contexts/TemplateContext";
export default function ContextWrapper({
children,
}: {
children: React.ReactNode;
}) {
return (
<SonProvider>
<ListProvider>
<TemplateProvider>{children}</TemplateProvider>
</ListProvider>
</SonProvider>
);
}

View file

@ -1,4 +1,5 @@
"use client";
// ui/components/Dashboard.tsx
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@ -12,31 +13,16 @@ import {
Legend,
} from "recharts";
import { ChartContainer, ChartConfig } from "@/components/ui/chart";
// Mock data - replace with actual API calls in production
const recentActivity = [
{
id: 1,
action: 'Son "Welcome Email" triggered',
timestamp: "2024-08-10T10:30:00Z",
},
{
id: 2,
action: 'New Son "Survey Request" created',
timestamp: "2024-08-10T09:15:00Z",
},
{
id: 3,
action: 'Son "Monthly Newsletter" modified',
timestamp: "2024-08-09T16:45:00Z",
},
];
const sonStats = [
{ name: "Welcome Email", executions: 120, success: 115, failure: 5 },
{ name: "Survey Request", executions: 80, success: 78, failure: 2 },
{ name: "Monthly Newsletter", executions: 50, success: 50, failure: 0 },
];
import { DashboardSkeleton } from "./DashboardSKeleton";
import { useRecentActivity } from "@/hooks/useRecentActivity";
import { useSonStats } from "@/hooks/useSonStats";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const chartConfig = {
executions: {
@ -45,7 +31,7 @@ const chartConfig = {
},
success: {
label: "Success",
color: "#60a5fa",
color: "#F97316",
},
failure: {
label: "Failure",
@ -53,20 +39,37 @@ const chartConfig = {
},
} satisfies ChartConfig;
const timeframeOptions = [
{ value: "1h", label: "1 Hour" },
{ value: "6h", label: "6 Hours" },
{ value: "12h", label: "12 Hours" },
{ value: "24h", label: "24 Hours" },
{ value: "168h", label: "1 Week" },
];
export default function Dashboard() {
const [isLoading, setIsLoading] = useState(true);
const [timeframe, setTimeframe] = useState("24h");
const {
activities,
loading: activitiesLoading,
error: activitiesError,
} = useRecentActivity();
const {
stats,
loading: statsLoading,
error: statsError,
} = useSonStats(timeframe);
useEffect(() => {
// Simulate API call
setTimeout(() => setIsLoading(false), 1000);
}, []);
if (activitiesLoading || statsLoading) {
return <DashboardSkeleton />;
}
if (isLoading) {
return <div>Loading...</div>;
if (activitiesError || statsError) {
return <div>Error loading dashboard data</div>;
}
return (
<div className="space-y-4">
<div className="flex-1 space-y-4">
<h1 className="text-2xl font-bold">Dashboard</h1>
<div className="grid gap-4 md:grid-cols-2">
@ -75,27 +78,43 @@ export default function Dashboard() {
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
{recentActivity.map((activity) => (
<Alert key={activity.id} className="mb-2">
<AlertTitle>{activity.action}</AlertTitle>
<AlertDescription>
{new Date(activity.timestamp).toLocaleString()}
</AlertDescription>
</Alert>
))}
{!activities && <div>No recent activity</div>}
{activities &&
activities.map((activity) => (
<Alert key={activity.id} className="mb-2">
<AlertTitle>{activity.action_type}</AlertTitle>
<AlertDescription>
{activity.description}
<br />
{new Date(activity.timestamp).toLocaleString()}
</AlertDescription>
</Alert>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Son Performance</CardTitle>
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select timeframe" />
</SelectTrigger>
<SelectContent>
{timeframeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
>
<BarChart data={sonStats}>
<BarChart data={stats}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />

View file

@ -0,0 +1,36 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
export function DashboardSkeleton() {
return (
<div className="flex-1 space-y-4">
<div className="h-8 w-1/4 bg-gray-200 rounded animate-pulse"></div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<div className="h-6 w-1/3 bg-gray-200 rounded animate-pulse"></div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-12 bg-gray-200 rounded animate-pulse"
></div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="h-6 w-1/3 bg-gray-200 rounded animate-pulse"></div>
</CardHeader>
<CardContent>
<div className="h-40 bg-gray-200 rounded animate-pulse"></div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -76,7 +76,7 @@ export function ManageSubscriberActionFields({
</div>
<FormDescription>
Default lists to add this user on creation. (Apart from the lists
for the ghost blog newsletters) p.e. "New Subscribers"
for the ghost blog newsletters) p.e. &quot;New Subscribers&quot;
</FormDescription>
<FormMessage />
</FormItem>

View file

@ -0,0 +1,9 @@
import ContextWrapper from "@/components/ContextWrapper";
export default function MinimalLayout({
children,
}: {
children: React.ReactNode;
}) {
return <main className="flex min-h-screen p-4">{children}</main>;
}

View file

@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import { useAuthContext } from "@/contexts/AuthContext";
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuthContext();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push("/login");
}
}, [user, loading, router]);
if (loading) {
return <div>Loading...</div>;
}
return user ? <>{children}</> : null;
}

70
ui/components/Sidebar.tsx Normal file
View file

@ -0,0 +1,70 @@
import { usePathname } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
LayoutDashboard,
ListPlus,
ListTree,
Settings,
Activity,
} from "lucide-react";
export const Sidebar = ({ isOpen }: { isOpen: boolean }) => {
const pathname = usePathname();
const mainMenuItems = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/sons/new", label: "Create Son", icon: ListPlus },
{ href: "/sons", label: "Manage Sons", icon: ListTree },
{ href: "/settings", label: "Settings", icon: Settings },
];
const observabilityMenuItems = [
{ href: "/observability/webhooks", label: "Webhook Logs", icon: Activity },
{ href: "/observability/son-logs", label: "Son Logs", icon: Activity },
];
return (
<aside
className={`bg-primary text-primary-foreground w-64 min-h-screen p-4 ${
isOpen ? "" : "hidden"
} md:block`}
>
<nav>
<ul>
{mainMenuItems.map((item) => (
<li key={item.href} className="mb-2">
<Link href={item.href}>
<Button
variant={pathname === item.href ? "secondary" : "ghost"}
className="w-full justify-start"
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</Button>
</Link>
</li>
))}
<li className="mt-4 mb-4">
<span className="text-sm font-semibold text-primary-foreground-50">
Observability
</span>
</li>
{observabilityMenuItems.map((item) => (
<li key={item.href} className="mb-2">
<Link href={item.href}>
<Button
variant={pathname === item.href ? "secondary" : "ghost"}
className="w-full justify-start"
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</Button>
</Link>
</li>
))}
</ul>
</nav>
</aside>
);
};

View file

@ -1,4 +1,3 @@
"use client";
import React from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@ -8,35 +7,36 @@ import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PlusCircle } from "lucide-react";
import { useSons } from "@/hooks/useSons";
import { useLists } from "@/hooks/useLists";
import { useTemplates } from "@/hooks/useTemplates";
import { useCustomToast } from "@/hooks/useCustomToast";
import { SonDetailsForm } from "./SonDetailsForm";
import { ActionForm } from "./ActionForm";
// Import or define your schema here
import { sonSchema } from "@/lib/schemas";
import { useSonContext } from "@/contexts/SonContext";
import { useListContext } from "@/contexts/ListContext";
import { useTemplateContext } from "@/contexts/TemplateContext";
type SonFormValues = z.infer<typeof sonSchema>;
export default function SonCreationForm() {
const { createSon } = useSons();
const { createSon } = useSonContext();
const { showToast } = useCustomToast();
const router = useRouter();
const { lists, loading: listsLoading, error: listsError } = useLists();
const { lists, loading: listsLoading, error: listsError } = useListContext();
const {
templates,
loading: templatesLoading,
error: templatesError,
} = useTemplates();
} = useTemplateContext();
const form = useForm<SonFormValues>({
resolver: zodResolver(sonSchema),
defaultValues: {
name: "",
trigger: "member_created",
delay: 0,
delay: "0s",
enabled: true,
actions: [
{
type: "send_transactional_email",

View file

@ -17,6 +17,7 @@ import {
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SonDetailsFormProps } from "@/lib/types";
import { Switch } from "@/components/ui/switch";
export function SonDetailsForm({ form }: SonDetailsFormProps) {
return (
@ -73,21 +74,38 @@ export function SonDetailsForm({ form }: SonDetailsFormProps) {
name="delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (minutes)</FormLabel>
<FormLabel>Delay</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value))}
placeholder="e.g., 30m, 2h, 1d, 1w"
/>
</FormControl>
<FormDescription>
Set a delay before the Son executes its actions.
Set a delay before the Son executes its actions. Use format like "30m" for 30 minutes, "2h" for 2 hours, "1d" for 1 day, or "1w" for 1 week.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Enabled</FormLabel>
<FormDescription>Enable or disable this Son.</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
);

View file

@ -1,4 +1,3 @@
"use client";
import React from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@ -11,8 +10,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Pencil, Trash2 } from "lucide-react";
import { useSons } from "@/hooks/useSons";
import { Pencil, Trash2, ToggleLeft, ToggleRight } from "lucide-react";
import { useSonContext } from "@/contexts/SonContext";
import {
AlertDialog,
AlertDialogAction,
@ -24,78 +23,154 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { SonListSkeleton } from "./SonListSkeleton";
import { useToast } from "@/components/ui/use-toast";
import { Son } from "@/lib/types";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
function formatDuration(duration: string): string {
console.log("duration", duration);
const match = duration.match(/^(\d+)([smhdw])$/);
if (!match) return duration;
const [, value, unit] = match;
const num = parseInt(value, 10);
switch (unit) {
case "s":
return `${num} second${num !== 1 ? "s" : ""}`;
case "m":
return `${num} minute${num !== 1 ? "s" : ""}`;
case "h":
return `${num} hour${num !== 1 ? "s" : ""}`;
case "d":
return `${num} day${num !== 1 ? "s" : ""}`;
case "w":
return `${num} week${num !== 1 ? "s" : ""}`;
default:
return duration;
}
}
export function SonList() {
const { sons, loading, error, deleteSon } = useSons();
const { sons, loading, error, deleteSon, updateSon } = useSonContext();
const { toast } = useToast();
if (loading) {
return <div>Loading sons...</div>;
return <SonListSkeleton />;
}
if (error) {
return <div>Error: {error.message}</div>;
}
if (sons.length === 0) {
return <div>No sons found. Create your first son!</div>;
}
const handleToggleEnabled = async (son: Son) => {
try {
await updateSon(son.id, { ...son, enabled: !son.enabled });
toast({
title: "Son Updated",
description: `${son.name} has been ${
!son.enabled ? "enabled" : "disabled"
}.`,
variant: "default",
});
} catch (error) {
toast({
title: "Error",
description: "Failed to update Son status.",
variant: "destructive",
});
}
};
return (
<Card>
<CardHeader>
<CardTitle>Your Sons</CardTitle>
</CardHeader>
<CardContent>
{sons.length === 0 && <div>No sons found. Create your first son!</div>}
{sons.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sons.map((son) => (
<TableRow key={son.id}>
<TableCell>{son.name}</TableCell>
<TableCell>{son.trigger}</TableCell>
<TableCell>
<div className="flex space-x-2">
<Link href={`/sons/${son.id}`}>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>Status</TableHead>
<TableHead>Delay</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sons.map((son) => (
<TableRow key={son.id}>
<TableCell>{son.name}</TableCell>
<TableCell>{son.trigger}</TableCell>
<TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleEnabled(son)}
>
{son.enabled ? (
<ToggleRight className="h-4 w-4 text-green-500" />
) : (
<ToggleLeft className="h-4 w-4 text-red-500" />
)}
</Button>
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will
permanently delete the Son.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteSon(son.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</TooltipTrigger>
<TooltipContent>
<p>{son.enabled ? "Enabled" : "Disabled"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell>{formatDuration(son.delay)}</TableCell>
<TableCell>
<div className="flex space-x-2">
<Link href={`/sons/${son.id}`}>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently
delete the Son.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteSon(son.id)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);

View file

@ -0,0 +1,49 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function SonListSkeleton() {
return (
<Card className="flex-1">
<CardHeader>
<CardTitle>Your Sons</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(5)].map((_, index) => (
<TableRow key={index}>
<TableCell>
<div className="h-4 bg-gray-300 rounded w-3/4 animate-pulse"></div>
</TableCell>
<TableCell>
<div className="h-4 bg-gray-300 rounded w-1/2 animate-pulse"></div>
</TableCell>
<TableCell>
<div className="flex space-x-2">
<div className="h-8 w-8 bg-gray-300 rounded-full animate-pulse"></div>
<div className="h-8 w-8 bg-gray-300 rounded-full animate-pulse"></div>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,242 @@
import React, { useState } from "react";
import { useSonLogs } from "@/hooks/useSonLogs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
PaginationEllipsis,
} from "@/components/ui/pagination";
import { ActionExecutionLog } from "@/lib/types";
import { useToast } from "@/components/ui/use-toast";
import Link from "next/link";
import { WebhookDetailsDialog } from "@/components/WebhookDetailsDialog";
import { Badge } from "@/components/ui/badge";
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
let color = "bg-gray-500";
switch (status.toLowerCase()) {
case "success":
color = "bg-green-500";
break;
case "failure":
color = "bg-red-500";
break;
case "warning":
color = "bg-yellow-500";
break;
case "pending":
color = "bg-blue-500";
break;
}
return <Badge className={`${color} text-white`}>{status}</Badge>;
};
const SonLogsPage: React.FC = () => {
const { logs, loading, error, pagination, fetchLogs, fetchActionLogs } =
useSonLogs();
const [selectedExecution, setSelectedExecution] = useState<string | null>(
null
);
const [actionLogs, setActionLogs] = useState<ActionExecutionLog[]>([]);
const [selectedWebhookLogId, setSelectedWebhookLogId] = useState<
string | null
>(null);
const { toast } = useToast();
const totalPages = Math.ceil(pagination.total / pagination.limit);
const currentPage = Math.floor(pagination.offset / pagination.limit) + 1;
const handlePageChange = (page: number) => {
const newOffset = (page - 1) * pagination.limit;
fetchLogs(newOffset);
};
const openActionLogs = async (executionId: string) => {
try {
setSelectedExecution(executionId);
const actionLogsData = await fetchActionLogs(executionId);
setActionLogs(actionLogsData);
} catch (error) {
toast({
title: "Error",
description: "Failed to fetch action logs",
variant: "destructive",
});
}
};
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div className="container mx-auto py-10">
<Card>
<CardHeader>
<CardTitle>Son Execution Logs</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Son Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Executed At</TableHead>
<TableHead>Webhook</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell>
<Link
href={`/sons/${log.son_id}`}
className="text-blue-600 hover:underline"
>
{log.sonName}
</Link>
</TableCell>
<TableCell>
<StatusBadge status={log.status} />
</TableCell>
<TableCell>
{new Date(log.executed_at).toLocaleString()}
</TableCell>
<TableCell>
<Button
variant="link"
onClick={() =>
setSelectedWebhookLogId(log.webhook_log_id)
}
>
View Webhook
</Button>
</TableCell>
<TableCell>
<Button onClick={() => openActionLogs(log.id)}>
View Actions
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pagination>
<PaginationContent>
{currentPage > 1 && (
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(currentPage - 1)}
/>
</PaginationItem>
)}
{[...Array(totalPages)].map((_, index) => {
const page = index + 1;
if (
page === 1 ||
page === totalPages ||
(page >= currentPage - 1 && page <= currentPage + 1)
) {
return (
<PaginationItem key={page}>
<PaginationLink
isActive={page === currentPage}
onClick={() => handlePageChange(page)}
>
{page}
</PaginationLink>
</PaginationItem>
);
} else if (
page === currentPage - 2 ||
page === currentPage + 2
) {
return <PaginationEllipsis key={page} />;
}
return null;
})}
{currentPage < totalPages && (
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(currentPage + 1)}
/>
</PaginationItem>
)}
</PaginationContent>
</Pagination>
</CardContent>
</Card>
<Dialog
open={!!selectedExecution}
onOpenChange={() => setSelectedExecution(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Action Logs for Execution {selectedExecution}
</DialogTitle>
</DialogHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Action Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Executed At</TableHead>
<TableHead>Error Message</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{actionLogs.map((log: ActionExecutionLog) => (
<TableRow key={log.id}>
<TableCell>{log.action_type}</TableCell>
<TableCell>
<StatusBadge status={log.status} />
</TableCell>
<TableCell>
{new Date(log.executed_at).toLocaleString()}
</TableCell>
<TableCell>{log.error_message || "N/A"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DialogContent>
</Dialog>
{selectedWebhookLogId && (
<WebhookDetailsDialog
logId={selectedWebhookLogId}
onClose={() => setSelectedWebhookLogId(null)}
/>
)}
</div>
);
};
export default SonLogsPage;

View file

@ -0,0 +1,258 @@
import React, { useState, useEffect } from "react";
import { UseFormReturn } from "react-hook-form";
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Trash2, Plus } from "lucide-react";
import { ListmonkTemplate } from "@/lib/types";
import Link from "next/link";
interface TransactionalEmailActionFieldsProps {
form: UseFormReturn<any>;
index: number;
templates: ListmonkTemplate[];
}
interface Header {
key: string;
value: string;
}
interface EditingField {
key: string;
newKey: string;
value: string;
}
export function TransactionalEmailActionFields({
form,
index,
templates,
}: TransactionalEmailActionFieldsProps) {
const [localData, setLocalData] = useState<Record<string, string>>({});
const [editingField, setEditingField] = useState<EditingField | null>(null);
useEffect(() => {
const formData = form.getValues(`actions.${index}.parameters.data`);
if (formData && Object.keys(formData).length > 0) {
setLocalData(formData);
}
}, [form, index]);
const addDataField = () => {
const newKey = `newField${Object.keys(localData).length}`;
setLocalData((prev) => ({ ...prev, [newKey]: "" }));
setEditingField({ key: newKey, newKey: newKey, value: "" });
};
const removeDataField = (keyToRemove: string) => {
setLocalData((prev) => {
const newData = { ...prev };
delete newData[keyToRemove];
return newData;
});
setEditingField(null);
};
const handleKeyChange = (oldKey: string, newKey: string) => {
setEditingField((prev) => (prev ? { ...prev, newKey } : null));
};
const handleValueChange = (key: string, value: string) => {
setLocalData((prev) => ({ ...prev, [key]: value }));
};
const handleKeyBlur = () => {
if (editingField && editingField.key !== editingField.newKey) {
setLocalData((prev) => {
const newData = { ...prev };
delete newData[editingField.key];
newData[editingField.newKey] = editingField.value;
return newData;
});
}
setEditingField(null);
};
useEffect(() => {
form.setValue(`actions.${index}.parameters.data`, localData);
}, [localData, form, index]);
return (
<>
{/* Template selection field remains unchanged */}
<FormField
control={form.control}
name={`actions.${index}.parameters.template_id`}
render={({ field }) => (
<FormItem>
<FormLabel>Template</FormLabel>
<Select
onValueChange={(value: string) => field.onChange(parseInt(value))}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a template" />
</SelectTrigger>
</FormControl>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id.toString()}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Check the available template expressions in this link -{" "}
<Link href="https://listmonk.app/docs/templating" target="_blank">
https://listmonk.app/docs/templating
</Link>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`actions.${index}.parameters.headers`}
render={({ field }) => (
<FormItem>
<FormLabel>Headers</FormLabel>
<div className="space-y-2">
{((field.value as Header[]) || []).map((header, headerIndex) => (
<div key={headerIndex} className="flex items-center space-x-2">
<Input
placeholder="Key"
value={header.key}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newHeaders = [...(field.value as Header[])];
newHeaders[headerIndex].key = e.target.value;
field.onChange(newHeaders);
}}
/>
<Input
placeholder="Value"
value={header.value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newHeaders = [...(field.value as Header[])];
newHeaders[headerIndex].value = e.target.value;
field.onChange(newHeaders);
}}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const newHeaders = (field.value as Header[]).filter(
(_, i) => i !== headerIndex
);
field.onChange(newHeaders);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newHeaders = [
...((field.value as Header[]) || []),
{ key: "", value: "" },
];
field.onChange(newHeaders);
}}
>
<Plus className="mr-2 h-4 w-4" /> Add Header
</Button>
</div>
<FormMessage />
<FormDescription>
Headers will be added on the email, you can use this to track
campaigns.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`actions.${index}.parameters.data`}
render={() => (
<FormItem>
<FormLabel>Additional Data</FormLabel>
<div className="space-y-2">
{Object.entries(localData).map(([key, value]) => (
<div key={key} className="flex items-center space-x-2">
<Input
placeholder="Key"
value={
editingField?.key === key ? editingField.newKey : key
}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleKeyChange(key, e.target.value)
}
onFocus={() => setEditingField({ key, newKey: key, value })}
onBlur={handleKeyBlur}
/>
<Input
placeholder="Value"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleValueChange(key, e.target.value)
}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDataField(key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addDataField}
>
<Plus className="mr-2 h-4 w-4" /> Add Data Field
</Button>
</div>
<FormMessage />
<FormDescription>
<p>Enter any additional data for the transactional email.</p>
<p>
Available in the template as{" "}
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
&#123;&#123; .Tx.Data.* &#125;&#125;
</code>
</p>
</FormDescription>
</FormItem>
)}
/>
</>
);
}

View file

@ -0,0 +1,167 @@
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { apiClient } from "@/lib/api-client";
import { useToast } from "@/components/ui/use-toast";
import { WebhookLog } from "@/lib/types";
interface WebhookDetailsDialogProps {
logId: string;
onClose: () => void;
}
export const WebhookDetailsDialog: React.FC<WebhookDetailsDialogProps> = ({
logId,
onClose,
}) => {
const [log, setLog] = useState<WebhookLog | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [replayLoading, setReplayLoading] = useState(false);
const { toast } = useToast();
useEffect(() => {
const fetchLogDetails = async () => {
try {
setLoading(true);
const response = await apiClient.get(`/webhook-logs/${logId}`);
setLog(response.data);
} catch (err) {
setError("Failed to fetch webhook details");
console.error(err);
} finally {
setLoading(false);
}
};
fetchLogDetails();
}, [logId]);
const handleReplay = async () => {
if (!log) return;
try {
setReplayLoading(true);
await apiClient.post(`/webhook-logs/${log.id}/replay`);
toast({
title: "Webhook Replayed",
description: "The webhook has been successfully replayed.",
});
} catch (err) {
console.error("Failed to replay webhook:", err);
toast({
title: "Replay Failed",
description: "Failed to replay the webhook. Please try again.",
variant: "destructive",
});
} finally {
setReplayLoading(false);
}
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-[80vw] max-h-[80vh] flex flex-col p-0">
<DialogHeader className="px-6 py-4">
<DialogTitle>Webhook Details</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-grow overflow-y-scroll">
<div className="px-6 py-4">
{loading ? (
<WebhookDetailsSkeleton />
) : error ? (
<div className="text-red-500">{error}</div>
) : log ? (
<>
<Button
onClick={handleReplay}
disabled={replayLoading}
className="mb-4"
>
{replayLoading ? "Replaying..." : "Replay Webhook"}
</Button>
<Tabs defaultValue="request" className="w-full">
<TabsList>
<TabsTrigger value="request">Request</TabsTrigger>
<TabsTrigger value="response">Response</TabsTrigger>
</TabsList>
<TabsContent value="request">
<div className="mt-4 space-y-4">
<ExpandableSection
title="Headers"
content={log.headers}
/>
<ExpandableSection title="Body" content={log.body} />
</div>
</TabsContent>
<TabsContent value="response">
<div className="mt-4 space-y-4">
<h3 className="font-semibold">Status Code</h3>
<p>{log.status_code}</p>
<ExpandableSection
title="Body"
content={log.response_body}
/>
</div>
</TabsContent>
</Tabs>
</>
) : null}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
// ... (ExpandableSection and WebhookDetailsSkeleton components remain the same)
interface ExpandableSectionProps {
title: string;
content: string;
}
const ExpandableSection: React.FC<ExpandableSectionProps> = ({
title,
content,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const toggleExpand = () => setIsExpanded(!isExpanded);
return (
<div className="flex flex-col overflow-y-auto">
<h3 className="font-semibold">{title}</h3>
<div
className={`mt-2 bg-muted p-2 rounded max-w-[75vw] overflow-auto ${
isExpanded ? "" : "max-h-40 overflow-hidden"
}`}
>
<pre className="whitespace-pre-wrap">
{JSON.stringify(JSON.parse(content), null, 2)}
</pre>
</div>
<Button variant="ghost" onClick={toggleExpand} className="mt-2">
{isExpanded ? "Show Less" : "Show More"}
</Button>
</div>
);
};
const WebhookDetailsSkeleton: React.FC = () => (
<div className="space-y-4">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-[20px] w-full" />
<Skeleton className="h-[100px] w-full" />
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-[100px] w-full" />
</div>
);

View file

@ -0,0 +1,59 @@
// Create a new file: ui/components/WebhookInfo.tsx
import React, { useState } from "react";
import { useSonContext } from "@/contexts/SonContext";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Eye, EyeOff } from "lucide-react";
export function WebhookInfo() {
const { webhook, webhookLoading, webhookError } = useSonContext();
const [showSecret, setShowSecret] = useState(false);
if (webhookLoading) {
return <div>Loading webhook information...</div>;
}
if (webhookError) {
return <div>Error loading webhook information: {webhookError.message}</div>;
}
if (!webhook) {
return <div>No webhook information available.</div>;
}
return (
<Card>
<CardHeader>
<CardTitle>Webhook Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium">Endpoint:</label>
<Input value={webhook.endpoint} readOnly />
</div>
<div>
<label className="text-sm font-medium">Secret:</label>
<div className="flex items-center space-x-2">
<Input
type={showSecret ? "text" : "password"}
value={webhook.secret}
readOnly
/>
<Button
variant="outline"
size="icon"
onClick={() => setShowSecret(!showSecret)}
>
{showSecret ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,96 @@
import React, { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { WebhookDetailsDialog } from "@/components/WebhookDetailsDialog";
import { useWebhookLogs } from "@/hooks/useWebhookLogs";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
export const WebhookTable: React.FC = () => {
const { logs, loading, error, fetchNextPage } = useWebhookLogs();
const [selectedLogId, setSelectedLogId] = useState<string | null>(null);
if (loading) return <TableSkeleton />;
if (error) return <ErrorDisplay message={error.message} />;
return (
<Card>
<CardHeader>
<CardTitle>Webhook Logs</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Method</TableHead>
<TableHead>Path</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration (ms)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow
key={log.id}
onClick={() => setSelectedLogId(log.id)}
className="cursor-pointer hover:bg-muted"
>
<TableCell>
{new Date(log.timestamp).toLocaleString()}
</TableCell>
<TableCell>{log.method}</TableCell>
<TableCell>{log.path}</TableCell>
<TableCell>{log.status_code}</TableCell>
<TableCell>{log.duration}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 flex justify-center">
<Button onClick={fetchNextPage}>Load More</Button>
</div>
</CardContent>
{selectedLogId && (
<WebhookDetailsDialog
logId={selectedLogId}
onClose={() => setSelectedLogId(null)}
/>
)}
</Card>
);
};
const TableSkeleton: React.FC = () => (
<Card>
<CardHeader>
<CardTitle>Webhook Logs</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="h-8 bg-gray-200 rounded animate-pulse" />
</div>
</CardContent>
</Card>
);
const ErrorDisplay: React.FC<{ message: string }> = ({ message }) => (
<Card>
<CardHeader>
<CardTitle>Error</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-500">{message}</p>
</CardContent>
</Card>
);

View file

@ -1,60 +1,17 @@
"use client";
import { useState } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
LayoutDashboard,
ListPlus,
ListTree,
Settings,
Menu,
} from "lucide-react";
const Sidebar = ({ isOpen }: { isOpen: boolean }) => {
const pathname = usePathname();
const menuItems = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/sons/new", label: "Create Son", icon: ListPlus },
{ href: "/sons", label: "Manage Sons", icon: ListTree },
{ href: "/settings", label: "Settings", icon: Settings },
];
return (
<aside
className={`bg-gray-800 text-white w-64 min-h-screen p-4 ${
isOpen ? "" : "hidden"
} md:block`}
>
<nav>
<ul>
{menuItems.map((item) => (
<li key={item.href} className="mb-2">
<Link href={item.href}>
<Button
variant={pathname === item.href ? "secondary" : "ghost"}
className="w-full justify-start"
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</Button>
</Link>
</li>
))}
</ul>
</nav>
</aside>
);
};
import { Menu } from "lucide-react";
import ContextWrapper from "@/components/ContextWrapper";
import { Sidebar } from "@/components/Sidebar";
import { AuthButton } from "./AuthButton";
export default function Layout({ children }: { children: React.ReactNode }) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen">
<div className="flex">
<Sidebar isOpen={isSidebarOpen} />
<div className="flex-1">
<div className="flex-1 flex flex-col">
<header className="bg-white shadow p-4 flex justify-between items-center">
<Button
variant="ghost"
@ -64,8 +21,11 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<Menu className="h-6 w-6" />
</Button>
<h1 className="text-xl font-bold">Ghost-Listmonk Connector</h1>
<AuthButton />
</header>
<main className="p-4">{children}</main>
<main className="flex-1 bg-gray-100 p-4 overflow-y-auto">
<ContextWrapper>{children}</ContextWrapper>
</main>
</div>
</div>
);

View file

@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
@ -18,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}

View file

@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {

View file

@ -4,16 +4,16 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},

View file

@ -5,25 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {

View file

@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
@ -35,10 +35,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))

View file

@ -1,7 +1,10 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import {
NameType,
Payload,
ValueType,
} from "recharts/types/component/DefaultTooltipContent"
import { cn } from "@/lib/utils"

120
ui/components/ui/dialog.tsx Normal file
View file

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View file

@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
@ -135,7 +133,7 @@ const FormDescription = React.forwardRef<
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
@ -157,7 +155,7 @@ const FormMessage = React.forwardRef<
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}

View file

@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}

View file

@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"

View file

@ -0,0 +1,121 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
DotsHorizontalIcon,
} from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeftIcon className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRightIcon className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View file

@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View file

@ -1,8 +1,11 @@
"use client"
import * as React from "react"
import {
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
@ -19,14 +22,14 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
@ -44,7 +47,7 @@ const SelectScrollUpButton = React.forwardRef<
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
@ -61,7 +64,7 @@ const SelectScrollDownButton = React.forwardRef<
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
@ -105,7 +108,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
@ -118,17 +121,16 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))

View file

@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"

Some files were not shown because too many files have changed in this diff Show more