From ff5b39241a055fbe30150123b1cf122818df27bc Mon Sep 17 00:00:00 2001 From: troneras Date: Mon, 12 Aug 2024 23:54:25 +0200 Subject: [PATCH] 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 --- .env.example | 19 + .env.local | 5 - .gitignore | 4 + CODE_OF_CONDUCT.md | 128 + Dockerfile | 30 +- LICENSE | 21 + Makefile | 38 + README.md | 170 +- create_migration.sh | 12 + database/db.go | 122 + .../000000_initial_migration.down.sql | 4 + .../000000_initial_migration.up.sql | 38 + .../migrations/000001_webhook_logs.down.sql | 1 + .../migrations/000001_webhook_logs.up.sql | 13 + database/migrations/000002_son_logs.down.sql | 2 + database/migrations/000002_son_logs.up.sql | 12 + .../000003_son_action_logs.down.sql | 1 + .../migrations/000003_son_action_logs.up.sql | 10 + .../000004_recent_activity.down.sql | 1 + .../migrations/000004_recent_activity.up.sql | 8 + ...05_increase_webhook_log_body_size.down.sql | 1 + ...0005_increase_webhook_log_body_size.up.sql | 1 + ghost_listmonk.db | Bin 12288 -> 28672 bytes ghost_monk.png | Bin 0 -> 133487 bytes go.mod | 26 +- go.sum | 138 +- handlers/auth.go | 97 + handlers/handlers.go | 47 + handlers/listmonk_handler.go | 39 + handlers/recent_activity_handler.go | 35 + handlers/son_execution_log_handler.go | 72 + handlers/son_handlers.go | 114 +- handlers/son_stats_handler.go | 38 + handlers/webhook_handler.go | 237 +- handlers/webhook_log_handler.go | 86 + main.go | 161 +- middleware/auth.go | 67 +- models/recent_activity.go | 11 + models/son.go | 57 +- models/son_execution_logger.go | 24 + models/user.go | 29 + models/webhook.go | 14 + previous_conversation.txt | 240 -- repopack-output-backend-go.txt | 1634 -------- routes/routes.go | 50 + services/database.go | 235 -- services/email_service.go | 98 + services/listmonk_client.go | 78 +- services/listmonk_client_test.go | 47 - services/magic_link_service.go | 51 + services/recent_activity_service.go | 53 + services/services.go | 51 + services/son_execution_logger.go | 187 + services/son_executor.go | 472 ++- services/son_storage.go | 93 +- services/son_storage_test.go | 82 - services/user_service.go | 92 + services/webhook_logger_service.go | 156 + services/webhook_service.go | 72 + start.sh | 3 + ui/README.md | 6 +- ui/app/layout.tsx | 31 - ui/app/page.tsx | 5 - ui/app/sons/layout.tsx | 3 - ui/app/sons/new/page.tsx | 10 - ui/components.json | 8 +- ui/components/ActionForm.tsx | 69 +- ui/components/AuthButton.tsx | 33 + ui/components/CampaignActionFields.tsx | 214 +- ui/components/ContextWrapper.tsx | 17 + ui/components/Dashboard.tsx | 109 +- ui/components/DashboardSKeleton.tsx | 36 + .../ManageSubscriberActionFields.tsx | 2 +- ui/components/MinimalLayout.tsx | 9 + ui/components/ProtectedRoute.tsx | 20 + ui/components/Sidebar.tsx | 70 + ui/components/SonCreationForm.tsx | 16 +- ui/components/SonDetailsForm.tsx | 26 +- ui/components/SonList.tsx | 191 +- ui/components/SonListSkeleton.tsx | 49 + ui/components/SonLogsPage.tsx | 242 ++ .../TransactionalEmailActionFields.tsx | 258 ++ ui/components/WebhookDetailsDialog.tsx | 167 + ui/components/WebhookInfo.tsx | 59 + ui/components/WebhookTable.tsx | 96 + ui/components/layout.tsx | 60 +- ui/components/ui/alert-dialog.tsx | 4 +- ui/components/ui/alert.tsx | 2 +- ui/components/ui/badge.tsx | 6 +- ui/components/ui/button.tsx | 19 +- ui/components/ui/card.tsx | 7 +- ui/components/ui/chart.tsx | 7 +- ui/components/ui/dialog.tsx | 120 + ui/components/ui/form.tsx | 6 +- ui/components/ui/input.tsx | 2 +- ui/components/ui/label.tsx | 2 - ui/components/ui/pagination.tsx | 121 + ui/components/ui/scroll-area.tsx | 46 + ui/components/ui/select.tsx | 26 +- ui/components/ui/separator.tsx | 2 - ui/components/ui/skeleton.tsx | 15 + ui/components/ui/switch.tsx | 6 +- ui/components/ui/table.tsx | 7 +- ui/components/ui/tabs.tsx | 53 + ui/components/ui/textarea.tsx | 2 +- ui/components/ui/toast.tsx | 60 +- ui/components/ui/toaster.tsx | 2 - ui/components/ui/tooltip.tsx | 28 + ui/components/ui/use-toast.ts | 2 - ui/contexts/AuthContext.tsx | 67 + ui/contexts/ListContext.tsx | 39 + ui/contexts/SonContext.tsx | 76 + ui/contexts/TemplateContext.tsx | 43 + ui/hooks/useInitialData.ts | 35 + ui/hooks/useLists.ts | 1 + ui/hooks/useRecentActivity.ts | 29 + ui/hooks/useSon.ts | 58 +- ui/hooks/useSonLogs.ts | 67 + ui/hooks/useSonStats.ts | 30 + ui/hooks/useSons.ts | 6 +- ui/hooks/useWebhook.ts | 29 + ui/hooks/useWebhookLogs.ts | 38 + ui/lib/api-client.ts | 39 +- ui/lib/schemas.ts | 17 +- ui/lib/types.ts | 59 + ui/next.config.mjs | 9 +- ui/package.json | 11 +- ui/pages/_app.tsx | 86 + ui/pages/_document.tsx | 13 + ui/pages/auth/verify.tsx | 41 + ui/pages/index.tsx | 10 + ui/pages/login.tsx | 63 + ui/pages/observability/son-logs.tsx | 11 + ui/pages/observability/webhooks.tsx | 15 + ui/pages/settings.tsx | 15 + .../[id]/page.tsx => pages/sons/[id].tsx} | 39 +- .../sons/page.tsx => pages/sons/index.tsx} | 14 +- ui/pages/sons/new.tsx | 15 + ui/pnpm-lock.yaml | 234 +- ui/{app => public}/favicon.ico | Bin ui/public/fonts/inter-var-latin.woff2 | Bin 0 -> 36532 bytes ui/repopack-output-ui.txt | 1984 ---------- ui/repopack-output.txt | 3373 +++++++++++++++++ ui/styles/globals.css | 108 +- ui/tailwind.config.ts | 6 +- ui/tsconfig.json | 8 +- ui/types/next-auth.d.ts | 11 + ui/webhook-dump.txt | 359 -- utils/config.go | 164 +- utils/crypto.go | 31 + utils/duration_parser.go | 43 + utils/jwt.go | 17 + utils/logger.go | 4 + utils/template_parser.go | 29 + 154 files changed, 9951 insertions(+), 5373 deletions(-) create mode 100644 .env.example delete mode 100644 .env.local create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100755 create_migration.sh create mode 100644 database/db.go create mode 100644 database/migrations/000000_initial_migration.down.sql create mode 100644 database/migrations/000000_initial_migration.up.sql create mode 100644 database/migrations/000001_webhook_logs.down.sql create mode 100644 database/migrations/000001_webhook_logs.up.sql create mode 100644 database/migrations/000002_son_logs.down.sql create mode 100644 database/migrations/000002_son_logs.up.sql create mode 100644 database/migrations/000003_son_action_logs.down.sql create mode 100644 database/migrations/000003_son_action_logs.up.sql create mode 100644 database/migrations/000004_recent_activity.down.sql create mode 100644 database/migrations/000004_recent_activity.up.sql create mode 100644 database/migrations/000005_increase_webhook_log_body_size.down.sql create mode 100644 database/migrations/000005_increase_webhook_log_body_size.up.sql create mode 100644 ghost_monk.png create mode 100644 handlers/auth.go create mode 100644 handlers/handlers.go create mode 100644 handlers/listmonk_handler.go create mode 100644 handlers/recent_activity_handler.go create mode 100644 handlers/son_execution_log_handler.go create mode 100644 handlers/son_stats_handler.go create mode 100644 handlers/webhook_log_handler.go create mode 100644 models/recent_activity.go create mode 100644 models/son_execution_logger.go create mode 100644 models/user.go create mode 100644 models/webhook.go delete mode 100644 previous_conversation.txt delete mode 100644 repopack-output-backend-go.txt create mode 100644 routes/routes.go delete mode 100644 services/database.go create mode 100644 services/email_service.go delete mode 100644 services/listmonk_client_test.go create mode 100644 services/magic_link_service.go create mode 100644 services/recent_activity_service.go create mode 100644 services/services.go create mode 100644 services/son_execution_logger.go delete mode 100644 services/son_storage_test.go create mode 100644 services/user_service.go create mode 100644 services/webhook_logger_service.go create mode 100644 services/webhook_service.go create mode 100755 start.sh delete mode 100644 ui/app/layout.tsx delete mode 100644 ui/app/page.tsx delete mode 100644 ui/app/sons/layout.tsx delete mode 100644 ui/app/sons/new/page.tsx create mode 100644 ui/components/AuthButton.tsx create mode 100644 ui/components/ContextWrapper.tsx create mode 100644 ui/components/DashboardSKeleton.tsx create mode 100644 ui/components/MinimalLayout.tsx create mode 100644 ui/components/ProtectedRoute.tsx create mode 100644 ui/components/Sidebar.tsx create mode 100644 ui/components/SonListSkeleton.tsx create mode 100644 ui/components/SonLogsPage.tsx create mode 100644 ui/components/TransactionalEmailActionFields.tsx create mode 100644 ui/components/WebhookDetailsDialog.tsx create mode 100644 ui/components/WebhookInfo.tsx create mode 100644 ui/components/WebhookTable.tsx create mode 100644 ui/components/ui/dialog.tsx create mode 100644 ui/components/ui/pagination.tsx create mode 100644 ui/components/ui/scroll-area.tsx create mode 100644 ui/components/ui/skeleton.tsx create mode 100644 ui/components/ui/tabs.tsx create mode 100644 ui/components/ui/tooltip.tsx create mode 100644 ui/contexts/AuthContext.tsx create mode 100644 ui/contexts/ListContext.tsx create mode 100644 ui/contexts/SonContext.tsx create mode 100644 ui/contexts/TemplateContext.tsx create mode 100644 ui/hooks/useInitialData.ts create mode 100644 ui/hooks/useRecentActivity.ts create mode 100644 ui/hooks/useSonLogs.ts create mode 100644 ui/hooks/useSonStats.ts create mode 100644 ui/hooks/useWebhook.ts create mode 100644 ui/hooks/useWebhookLogs.ts create mode 100644 ui/pages/_app.tsx create mode 100644 ui/pages/_document.tsx create mode 100644 ui/pages/auth/verify.tsx create mode 100644 ui/pages/index.tsx create mode 100644 ui/pages/login.tsx create mode 100644 ui/pages/observability/son-logs.tsx create mode 100644 ui/pages/observability/webhooks.tsx create mode 100644 ui/pages/settings.tsx rename ui/{app/sons/[id]/page.tsx => pages/sons/[id].tsx} (89%) rename ui/{app/sons/page.tsx => pages/sons/index.tsx} (65%) create mode 100644 ui/pages/sons/new.tsx rename ui/{app => public}/favicon.ico (100%) create mode 100644 ui/public/fonts/inter-var-latin.woff2 delete mode 100644 ui/repopack-output-ui.txt create mode 100644 ui/repopack-output.txt create mode 100644 ui/types/next-auth.d.ts delete mode 100644 ui/webhook-dump.txt create mode 100644 utils/crypto.go create mode 100644 utils/duration_parser.go create mode 100644 utils/jwt.go create mode 100644 utils/template_parser.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b9dce64 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.local b/.env.local deleted file mode 100644 index 4898d33..0000000 --- a/.env.local +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34103f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.txt +main +.env.local +.env diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4cfb7bf --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/Dockerfile b/Dockerfile index 9a0496d..33441f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +CMD ["./start.sh"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc24bba --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7a258e9 --- /dev/null +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 00e2db0..85bfa80 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/create_migration.sh b/create_migration.sh new file mode 100755 index 0000000..7acb6bf --- /dev/null +++ b/create_migration.sh @@ -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 " + exit 1 +fi + +# Create the migration +migrate create -ext sql -dir database/migrations -seq $1 + +echo "Migration created successfully." diff --git a/database/db.go b/database/db.go new file mode 100644 index 0000000..d32b3c9 --- /dev/null +++ b/database/db.go @@ -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 +} diff --git a/database/migrations/000000_initial_migration.down.sql b/database/migrations/000000_initial_migration.down.sql new file mode 100644 index 0000000..dea7294 --- /dev/null +++ b/database/migrations/000000_initial_migration.down.sql @@ -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; \ No newline at end of file diff --git a/database/migrations/000000_initial_migration.up.sql b/database/migrations/000000_initial_migration.up.sql new file mode 100644 index 0000000..af22804 --- /dev/null +++ b/database/migrations/000000_initial_migration.up.sql @@ -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 +); \ No newline at end of file diff --git a/database/migrations/000001_webhook_logs.down.sql b/database/migrations/000001_webhook_logs.down.sql new file mode 100644 index 0000000..82a5a77 --- /dev/null +++ b/database/migrations/000001_webhook_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS webhook_logs; \ No newline at end of file diff --git a/database/migrations/000001_webhook_logs.up.sql b/database/migrations/000001_webhook_logs.up.sql new file mode 100644 index 0000000..fb5b468 --- /dev/null +++ b/database/migrations/000001_webhook_logs.up.sql @@ -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 +); \ No newline at end of file diff --git a/database/migrations/000002_son_logs.down.sql b/database/migrations/000002_son_logs.down.sql new file mode 100644 index 0000000..9da9d3f --- /dev/null +++ b/database/migrations/000002_son_logs.down.sql @@ -0,0 +1,2 @@ + +DROP TABLE IF EXISTS son_execution_logs; \ No newline at end of file diff --git a/database/migrations/000002_son_logs.up.sql b/database/migrations/000002_son_logs.up.sql new file mode 100644 index 0000000..e650cb4 --- /dev/null +++ b/database/migrations/000002_son_logs.up.sql @@ -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')) +); diff --git a/database/migrations/000003_son_action_logs.down.sql b/database/migrations/000003_son_action_logs.down.sql new file mode 100644 index 0000000..9481d2e --- /dev/null +++ b/database/migrations/000003_son_action_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS son_execution_action_logs; \ No newline at end of file diff --git a/database/migrations/000003_son_action_logs.up.sql b/database/migrations/000003_son_action_logs.up.sql new file mode 100644 index 0000000..d810f3e --- /dev/null +++ b/database/migrations/000003_son_action_logs.up.sql @@ -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')) +); \ No newline at end of file diff --git a/database/migrations/000004_recent_activity.down.sql b/database/migrations/000004_recent_activity.down.sql new file mode 100644 index 0000000..92817f3 --- /dev/null +++ b/database/migrations/000004_recent_activity.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS recent_activity; \ No newline at end of file diff --git a/database/migrations/000004_recent_activity.up.sql b/database/migrations/000004_recent_activity.up.sql new file mode 100644 index 0000000..fc846fa --- /dev/null +++ b/database/migrations/000004_recent_activity.up.sql @@ -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 +); \ No newline at end of file diff --git a/database/migrations/000005_increase_webhook_log_body_size.down.sql b/database/migrations/000005_increase_webhook_log_body_size.down.sql new file mode 100644 index 0000000..763f236 --- /dev/null +++ b/database/migrations/000005_increase_webhook_log_body_size.down.sql @@ -0,0 +1 @@ +ALTER TABLE webhook_logs MODIFY COLUMN body TEXT; \ No newline at end of file diff --git a/database/migrations/000005_increase_webhook_log_body_size.up.sql b/database/migrations/000005_increase_webhook_log_body_size.up.sql new file mode 100644 index 0000000..389d531 --- /dev/null +++ b/database/migrations/000005_increase_webhook_log_body_size.up.sql @@ -0,0 +1 @@ +ALTER TABLE webhook_logs MODIFY COLUMN body LONGTEXT; \ No newline at end of file diff --git a/ghost_listmonk.db b/ghost_listmonk.db index 9ddb8cf8ce24cbaecd7c382471a367e47dea4ab2..8efbd3c84a0a3e0a98d4df0f957cf2119e650bb8 100644 GIT binary patch literal 28672 zcmeI)&yE{47y$5@O*SE0vO^`R9FWi`J+v$8$o6>tI6&zZf>hCMyWNdMi>k<;@oYo$ zXPac%7Sux!LgK`eAfBSVaNt^rC*Tb@Voy?%Eu;+PvWG9yBr_iG*xzqv54Q7VZ})n4 zqTF;i>Whi%S#MdkZC!C)%d#B3mG$=GRnR-d7YF*&UfN%^>sXDieyBQsSf%PS%PBd( zZhp74bZ6qf)6%h7Z)h-EV!q@7}t7ZD;%T9rv@=olEA_ z(O8W--6T7i6{tst-H{r1^pNoreaZc6J1t#r%P*B`*UsBkcaW$@}e(IylHHly%kS%I4hcUb>5Y~^-~Al?Pa|*z1b?3Y9H2C^(MPq zhh!h?(@*Vk?c6#0+hNv)@o+FU+uDf^n37kwKM;MjK$?uY2M21jC`eRKJYF1%c+xeO zG~_eYfw_5!d-dkc>#gk@Sy?<%Vxp2|>DetgOkOTGF<)3K)!sk1DrOTwhnROWmVd)M zQB}R!&SlHFyg4w(@B#r4009sH0T2KI5C8!X009sHf&VWs-C5gczq56A`gUb^m*JQt zNhlEu1BzH0BuFyBQR*`(WJrWeBXaECIvU^atG?7z5c9z>IsW3C`sDGUsyFLnHAp&> zkr<3;!)ej$WU~PEOZ7uB(&O=oo=vDXb$!)8?CBx1p2w&+y(drZQA`=a5h8(0Xwwgx zj5L_fA{O}UJxrSzudWf0S&ivOcBQ?wb@qN`Z%sG) zx18tt4PGDs0w4eaAOHd&00JNY0w4eaAOHfVLEvJcZReRcS-VhZSMtnA%+~*(TF%qc z5FYda1V8`;KmY_l00ck)1V8`;KmY_lV5I=J3vCjlAx={dBVVNYI~9>1A{i$f37!y= zc>0?GD*kdPd*bVdM^_F^rbHti_805_E3XbGKmY_l00ck)1V8`;KmY_l00ck)1Wu`d zS^v*7BVgA5OVzuU^P}_b=1-d+RDY@7ee?HI8WeO31V8`;KmY_l00ck)1V8`;@(2ve z#ddoyWE6V|jgiOwKjod-@l%0(DSEb|PTmQ%ySrpKCo*q~Tvj#KIfv*hbx z$u!6X*XyrIj6@J(#0Zlpl8J|WPp7ym7NkmdB*)6_$p%T#q*23*Xr!~$$vl;NxSmCb zQx#yDAe~Py5EC>)oQPB(iZl^Ek#Us1MuT3H@&*lAs1w^cpGR0U4N8n-nFJmpI?Wxi zKq$mn9HJl;Vaj}hxk~3fB*=900@8p2!H?xfB*=900^8W u0h2PHXWFN1D(0E4DP>p8G0n1WD(0CUDVmCTrZoztVxH-UEdM{xG{T>3y=-Ct literal 12288 zcmeI1&2HO95XVV5Z6yP?axshmJ#2V!gqp&Ba3zYp)JcTENE|el1T+W?Vo8ov1`?%` zR1zad0P7GS?-HPI(xSIKN{`*uM_s6g_|l$cf#vSZ4tM$g&1sgsXHU`+$Z(oZR7r$& z+p=xz2SO~%+Sh$Y_f50)kGs#0jXzTpSgtIsGEYY%$gdpYgt{R9vQJ zSF_vF5$B*vNJ3qlKh!7pPtUqb|9qBg`}Kj1qsrHZ-yhgknkDe2_+_kX`%ulx>Cz6( z2Zzi&OcL*92W&_H2_OL^fCP{L5b%%(+2)^(qmhqHM! zPK%!*dGXdMFK6Hc&T2V~)num9QRW;wGnMONvxK~Gg13&2FZJZb#j#UBmW0{#wG)*2 z9JD=;UP4^zO*V(=w@WqB;f^`ZSTLE5b$J=4i4(AQ?=Cpyo=bg~F~Vsecp&^HZwc=C zl=AN=5A?7J;e@h4(16pXFPUJR%XNrG_}k#9@ztx|Gf-Y4V>VrBK3n zAZ5T?O;1Qh1^4Balud}F&31>ig=n=_5m%4w>eU0gI=I`Yn#?NcQ5tnzkkQcfI`Po; z6>Ymz#h^g?QbAOL&Z-td_*%+3!`C7vDpMl}b$*ICPqo_Zum3x%{Ni6im`O{Mu@+~Z z-(g!qbWTLTg-%Pe?Rg#MG1gv%?ACwP|K>kf^$+#WdWsDRAOR$R1dsp{Kmter2_OL^ jfCP{L68O&u+}j!0H@O}%zwH}0xjU&G?F?!+xt90~jt?_m diff --git a/ghost_monk.png b/ghost_monk.png new file mode 100644 index 0000000000000000000000000000000000000000..982af5e01eaae79dd586298deebf87a8afcd9c27 GIT binary patch literal 133487 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ħ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 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) diff --git a/handlers/son_stats_handler.go b/handlers/son_stats_handler.go new file mode 100644 index 0000000..fc2492a --- /dev/null +++ b/handlers/son_stats_handler.go @@ -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) +} diff --git a/handlers/webhook_handler.go b/handlers/webhook_handler.go index 1f29744..86b8305 100644 --- a/handlers/webhook_handler.go +++ b/handlers/webhook_handler.go @@ -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") } diff --git a/handlers/webhook_log_handler.go b/handlers/webhook_log_handler.go new file mode 100644 index 0000000..72c0c2f --- /dev/null +++ b/handlers/webhook_log_handler.go @@ -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) +} diff --git a/main.go b/main.go index 990888c..df06e2c 100644 --- a/main.go +++ b/main.go @@ -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) } diff --git a/middleware/auth.go b/middleware/auth.go index 29851ce..f8a2484 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -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 + } } } diff --git a/models/recent_activity.go b/models/recent_activity.go new file mode 100644 index 0000000..6d3640a --- /dev/null +++ b/models/recent_activity.go @@ -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"` +} diff --git a/models/son.go b/models/son.go index ebe39bc..9e9e126 100644 --- a/models/son.go +++ b/models/son.go @@ -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) } diff --git a/models/son_execution_logger.go b/models/son_execution_logger.go new file mode 100644 index 0000000..387adef --- /dev/null +++ b/models/son_execution_logger.go @@ -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"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..d57fc45 --- /dev/null +++ b/models/user.go @@ -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"` +} diff --git a/models/webhook.go b/models/webhook.go new file mode 100644 index 0000000..f8884b7 --- /dev/null +++ b/models/webhook.go @@ -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"` +} diff --git a/previous_conversation.txt b/previous_conversation.txt deleted file mode 100644 index 04adcf1..0000000 --- a/previous_conversation.txt +++ /dev/null @@ -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 CopyRetryClaude’s 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. \ No newline at end of file diff --git a/repopack-output-backend-go.txt b/repopack-output-backend-go.txt deleted file mode 100644 index 94aa619..0000000 --- a/repopack-output-backend-go.txt +++ /dev/null @@ -1,1634 +0,0 @@ -================================================================ -Repopack Output File -================================================================ - -This file was generated by Repopack on: 2024-08-12T00:02:56.842Z - -Purpose: --------- -This file contains a packed representation of the entire repository's contents. -It is designed to be easily consumable by AI systems for analysis, code review, -or other automated processes. - -File Format: ------------- -The content is organized as follows: -1. This header section -2. Repository structure -3. Multiple file entries, each consisting of: - a. A separator line (================) - b. The file path (File: path/to/file) - c. Another separator line - d. The full contents of the file - e. A blank line - -Usage Guidelines: ------------------ -1. This file should be treated as read-only. Any changes should be made to the - original repository files, not this packed version. -2. When processing this file, use the separators and "File:" markers to - distinguish between different files in the repository. -3. Be aware that this file may contain sensitive information. Handle it with - the same level of security as you would the original repository. - -Notes: ------- -- Some files may have been excluded based on .gitignore rules and Repopack's - configuration. -- Binary files are not included in this packed representation. Please refer to - the Repository Structure section for a complete list of file paths, including - binary files. - - - -For more information about Repopack, visit: https://github.com/yamadashy/repopack - -================================================================ -Repository Structure -================================================================ -handlers/ - son_handlers.go - webhook_handler.go -middleware/ - auth.go -models/ - son.go -services/ - database.go - listmonk_client_test.go - listmonk_client.go - son_executor.go - son_storage_test.go - son_storage.go -utils/ - config.go - errors.go - logger.go - pretty_print.go - uuid.go -.env.local -docker-compose.yml -Dockerfile -go.mod -main.go - -================================================================ -Repository Files -================================================================ - -================ -File: handlers/son_handlers.go -================ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/troneras/ghost-listmonk-connector/models" - "github.com/troneras/ghost-listmonk-connector/services" - "github.com/troneras/ghost-listmonk-connector/utils" -) - -type SonHandler struct { - storage *services.SonStorage -} - -type ListmonkHandler struct { - client *services.ListmonkClient -} - -func NewSonHandler(storage *services.SonStorage) *SonHandler { - return &SonHandler{storage: storage} -} - -func NewListmonkHandler(client *services.ListmonkClient) *ListmonkHandler { - return &ListmonkHandler{client: client} -} - -func (h *ListmonkHandler) GetLists(c *gin.Context) { - lists, err := h.client.GetLists() - if err != nil { - utils.ErrorLogger.Errorf("Failed to get lists: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"data": lists}) -} - -func (h *ListmonkHandler) GetTemplates(c *gin.Context) { - templates, err := h.client.GetTemplates() - if err != nil { - utils.ErrorLogger.Errorf("Failed to get templates: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"data": templates}) -} - -func (h *SonHandler) Create(c *gin.Context) { - var son models.Son - if err := c.ShouldBindJSON(&son); err != nil { - utils.ErrorLogger.Errorf("Invalid Son data: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.storage.Create(&son); err != nil { - utils.ErrorLogger.Errorf("Failed to create Son: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - utils.InfoLogger.Infof("Created Son: %s", utils.PrettyPrint(son)) - c.JSON(http.StatusCreated, son) -} - -func (h *SonHandler) Get(c *gin.Context) { - id := c.Param("id") - son, err := h.storage.Get(id) - if err != nil { - if err == services.ErrSonNotFound { - utils.ErrorLogger.Errorf("Son not found: %s", id) - c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"}) - } else { - utils.ErrorLogger.Errorf("Failed to get Son: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } - return - } - - utils.InfoLogger.Infof("Retrieved Son: %s", utils.PrettyPrint(son)) - c.JSON(http.StatusOK, son) -} - -func (h *SonHandler) Update(c *gin.Context) { - id := c.Param("id") - var son models.Son - if err := c.ShouldBindJSON(&son); err != nil { - utils.ErrorLogger.Errorf("Invalid Son data: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - son.ID = id - if err := h.storage.Update(son); err != nil { - if err == services.ErrSonNotFound { - utils.ErrorLogger.Errorf("Son not found for update: %s", id) - c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"}) - } else { - utils.ErrorLogger.Errorf("Failed to update Son: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } - return - } - - utils.InfoLogger.Infof("Updated Son: %s", utils.PrettyPrint(son)) - c.JSON(http.StatusOK, son) -} - -func (h *SonHandler) Delete(c *gin.Context) { - id := c.Param("id") - if err := h.storage.Delete(id); err != nil { - if err == services.ErrSonNotFound { - utils.ErrorLogger.Errorf("Son not found for deletion: %s", id) - c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"}) - } else { - utils.ErrorLogger.Errorf("Failed to delete Son: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } - return - } - - utils.InfoLogger.Infof("Deleted Son: %s", id) - c.JSON(http.StatusOK, gin.H{"message": "Son deleted successfully"}) -} - -func (h *SonHandler) List(c *gin.Context) { - sons, err := h.storage.List() - - if err != nil { - utils.ErrorLogger.Errorf("Failed to list Sons: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - utils.InfoLogger.Infof("Retrieved %d Sons", len(sons)) - c.JSON(http.StatusOK, sons) -} - -================ -File: handlers/webhook_handler.go -================ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/troneras/ghost-listmonk-connector/models" - "github.com/troneras/ghost-listmonk-connector/services" - "github.com/troneras/ghost-listmonk-connector/utils" -) - -type WebhookHandler struct { - sonStorage *services.SonStorage - executor *services.SonExecutor -} - -func NewWebhookHandler(sonStorage *services.SonStorage, executor *services.SonExecutor) *WebhookHandler { - return &WebhookHandler{ - sonStorage: sonStorage, - executor: executor, - } -} - -func (h *WebhookHandler) HandleWebhook(c *gin.Context) { - var webhookData map[string]interface{} - if err := c.ShouldBindJSON(&webhookData); err != nil { - utils.ErrorLogger.Errorf("Invalid webhook data: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook data"}) - return - } - - utils.InfoLogger.Infof("Received webhook: %s", utils.PrettyPrint(webhookData)) - - triggerType, err := determineTriggerType(webhookData) - if err != nil { - utils.ErrorLogger.Errorf("Unable to determine trigger type: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Unable to determine trigger type"}) - return - } - - utils.InfoLogger.Infof("Determined trigger type: %s", triggerType) - - // Find and execute relevant Sons - sons, err := h.sonStorage.List() - - if err != nil { - utils.ErrorLogger.Errorf("Failed to list Sons: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list Sons"}) - return - } - - executedCount := 0 - for _, son := range sons { - if son.Trigger == triggerType { - go func(s models.Son) { - utils.InfoLogger.Infof("Executing Son %s for trigger %s", s.ID, triggerType) - h.executor.ExecuteSon(s, webhookData) - }(son) - executedCount++ - } - } - - utils.InfoLogger.Infof("Executed %d Sons for trigger %s", executedCount, triggerType) - c.JSON(http.StatusOK, gin.H{"message": "Webhook processed successfully", "sons_executed": executedCount}) -} - -func determineTriggerType(webhookData map[string]interface{}) (models.TriggerType, error) { - if member, ok := webhookData["member"].(map[string]interface{}); ok { - if _, ok := member["current"]; ok { - if _, ok := member["previous"]; ok { - return models.TriggerMemberUpdated, nil - } - return models.TriggerMemberCreated, nil - } - // Assuming member deletion doesn't have 'current' field - return models.TriggerMemberDeleted, nil - } - - if post, ok := webhookData["post"].(map[string]interface{}); ok { - if status, ok := post["status"].(string); ok && status == "published" { - return models.TriggerPostPublished, nil - } - if status, ok := post["status"].(string); ok && status == "scheduled" { - return models.TriggerPostScheduled, nil - } - } - - if page, ok := webhookData["page"].(map[string]interface{}); ok { - if status, ok := page["status"].(string); ok && status == "published" { - return models.TriggerPagePublished, nil - } - } - - return "", utils.NewError("UnknownTriggerType", "Unable to determine trigger type from webhook data") -} - -================ -File: middleware/auth.go -================ -package middleware - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -func APIKeyAuth(validAPIKey string) gin.HandlerFunc { - return func(c *gin.Context) { - apiKey := c.GetHeader("Authorization") - if apiKey == "" { - apiKey = c.Query("api_key") - } - - if apiKey != "Bearer "+validAPIKey { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) - c.Abort() - return - } - - c.Next() - } -} - -================ -File: models/son.go -================ -package models - -import ( - "encoding/json" - "errors" - "time" -) - -type TriggerType string -type ActionType string - -const ( - TriggerMemberCreated TriggerType = "member_created" - TriggerMemberDeleted TriggerType = "member_deleted" - TriggerMemberUpdated TriggerType = "member_updated" - TriggerPagePublished TriggerType = "page_published" - TriggerPostPublished TriggerType = "post_published" - TriggerPostScheduled TriggerType = "post_scheduled" -) - -const ( - ActionSendTransactionalEmail ActionType = "send_transactional_email" - ActionManageSubscriber ActionType = "manage_subscriber" - ActionCreateCampaign ActionType = "create_campaign" -) - -type Son struct { - ID string `json:"id"` - Name string `json:"name"` - Trigger TriggerType `json:"trigger"` - Delay Duration `json:"delay"` - Actions []Action `json:"actions"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type Action struct { - Type ActionType `json:"type"` - Parameters map[string]any `json:"parameters"` -} - -// Duration is a custom type to handle time.Duration in JSON -type Duration time.Duration - -func (d Duration) MarshalJSON() ([]byte, error) { - return json.Marshal(time.Duration(d).Minutes()) -} - -func (d *Duration) UnmarshalJSON(b []byte) error { - var v interface{} - if err := json.Unmarshal(b, &v); err != nil { - return err - } - switch value := v.(type) { - case float64: - *d = Duration(time.Duration(value) * time.Minute) - return nil - case string: - tmp, err := time.ParseDuration(value) - if err != nil { - return err - } - *d = Duration(tmp) - return nil - default: - return errors.New("invalid duration") - } -} - -================ -File: services/database.go -================ -package services - -import ( - "database/sql" - "encoding/json" - "time" - - _ "github.com/mattn/go-sqlite3" - "github.com/troneras/ghost-listmonk-connector/models" - "github.com/troneras/ghost-listmonk-connector/utils" -) - -type Database struct { - db *sql.DB -} - -func NewDatabase(dbPath string) (*Database, error) { - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - return nil, err - } - - if err := db.Ping(); err != nil { - return nil, err - } - - database := &Database{db: db} - if err := database.createTables(); err != nil { - return nil, err - } - - return database, nil -} - -func (d *Database) createTables() error { - _, err := d.db.Exec(` - CREATE TABLE IF NOT EXISTS sons ( - id TEXT PRIMARY KEY, - name TEXT, - trigger TEXT, - delay INTEGER, - created_at DATETIME, - updated_at DATETIME - ); - CREATE TABLE IF NOT EXISTS actions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - son_id TEXT, - type TEXT, - parameters TEXT, - FOREIGN KEY (son_id) REFERENCES sons(id) - ); - `) - return err -} - -func (d *Database) CreateSon(son *models.Son) error { - son.ID = utils.GenerateUUID() - son.CreatedAt = time.Now() - son.UpdatedAt = time.Now() - - tx, err := d.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - _, err = tx.Exec( - "INSERT INTO sons (id, name, trigger, delay, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - son.ID, son.Name, son.Trigger, int64(son.Delay), son.CreatedAt, son.UpdatedAt, - ) - if err != nil { - return err - } - - for _, action := range son.Actions { - parametersJSON, err := json.Marshal(action.Parameters) - if err != nil { - return err - } - _, err = tx.Exec( - "INSERT INTO actions (son_id, type, parameters) VALUES (?, ?, ?)", - son.ID, action.Type, string(parametersJSON), - ) - if err != nil { - return err - } - } - - return tx.Commit() -} - -func (d *Database) GetSon(id string) (*models.Son, error) { - son := &models.Son{} - err := d.db.QueryRow( - "SELECT id, name, trigger, delay, created_at, updated_at FROM sons WHERE id = ?", - id, - ).Scan(&son.ID, &son.Name, &son.Trigger, &son.Delay, &son.CreatedAt, &son.UpdatedAt) - if err != nil { - if err == sql.ErrNoRows { - return nil, ErrSonNotFound - } - return nil, err - } - - rows, err := d.db.Query("SELECT type, parameters FROM actions WHERE son_id = ?", id) - if err != nil { - return nil, err - } - defer rows.Close() - - for rows.Next() { - var action models.Action - var parametersJSON string - err := rows.Scan(&action.Type, ¶metersJSON) - 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, ¶metersJSON) - if err != nil { - return nil, err - } - err = json.Unmarshal([]byte(parametersJSON), &action.Parameters) - if err != nil { - return nil, err - } - sons[i].Actions = append(sons[i].Actions, action) - } - } - - return sons, nil -} - -================ -File: services/listmonk_client_test.go -================ -package services - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestListmonkClient(t *testing.T) { - // Create a mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/tx": - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"data": true}`)) - case "/api/subscribers": - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"data": {"id": 1}}`)) - case "/api/campaigns": - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"data": {"id": 1}}`)) - default: - http.Error(w, "Not found", http.StatusNotFound) - } - })) - defer server.Close() - - // Create a client that uses the mock server URL - client := &ListmonkClient{ - baseURL: server.URL, - client: server.Client(), - } - - // Test SendTransactionalEmail - err := client.SendTransactionalEmail(1, "test@example.com", map[string]interface{}{"name": "Test"}) - assert.NoError(t, err) - - // Test ManageSubscriber - err = client.ManageSubscriber("test@example.com", "Test User", "enabled", []int{1}) - assert.NoError(t, err) - - // Test CreateCampaign - err = client.CreateCampaign("Test Campaign", "Test Subject", []int{1}, 1, "2023-01-01T00:00:00Z") - assert.NoError(t, err) -} - -================ -File: services/listmonk_client.go -================ -package services - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/troneras/ghost-listmonk-connector/utils" -) - -type ListmonkList struct { - ID int `json:"id"` - Name string `json:"name"` -} - -type ListmonkTemplate struct { - ID int `json:"id"` - Name string `json:"name"` -} - -type ListmonkClient struct { - baseURL string - client *http.Client -} - -func NewListmonkClient(config *utils.Config) *ListmonkClient { - return &ListmonkClient{ - baseURL: config.ListmonkURL, - client: &http.Client{}, - } -} - -func (c *ListmonkClient) GetLists() ([]ListmonkList, error) { - resp, err := c.client.Get(c.baseURL + "/api/lists?page=1&per_page=100") - if err != nil { - return nil, fmt.Errorf("error fetching lists: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - var result struct { - Data struct { - Results []ListmonkList `json:"results"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("error decoding response: %w", err) - } - - return result.Data.Results, nil -} - -func (c *ListmonkClient) GetTemplates() ([]ListmonkTemplate, error) { - resp, err := c.client.Get(c.baseURL + "/api/templates?page=1&per_page=100") - if err != nil { - return nil, fmt.Errorf("error fetching templates: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - var result struct { - Data []ListmonkTemplate `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("error decoding response: %w", err) - } - - return result.Data, nil -} - -func (c *ListmonkClient) SendTransactionalEmail(templateID int, subscriberEmail string, data map[string]interface{}) error { - payload := map[string]interface{}{ - "subscriber_email": subscriberEmail, - "template_id": templateID, - "data": data, - } - - jsonPayload, err := json.Marshal(payload) - if err != nil { - utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err) - return fmt.Errorf("failed to marshal payload: %w", err) - } - - resp, err := c.client.Post(c.baseURL+"/api/tx", "application/json", bytes.NewBuffer(jsonPayload)) - if err != nil { - utils.ErrorLogger.Errorf("Failed to send transactional email: %v", err) - return fmt.Errorf("failed to send transactional email: %w", err) - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - } - - utils.InfoLogger.Infof("Sent transactional email to %s using template %d", subscriberEmail, templateID) - return nil -} - -func (c *ListmonkClient) ManageSubscriber(email string, name string, status string, lists []int) error { - payload := map[string]interface{}{ - "email": email, - "name": name, - "status": status, - "lists": lists, - "preconfirm_subscriptions": true, - } - - jsonPayload, err := json.Marshal(payload) - if err != nil { - utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err) - return fmt.Errorf("failed to marshal payload: %w", err) - } - - resp, err := c.client.Post(c.baseURL+"/api/subscribers", "application/json", bytes.NewBuffer(jsonPayload)) - if err != nil { - utils.ErrorLogger.Errorf("Failed to manage subscriber: %v", err) - return fmt.Errorf("failed to manage subscriber: %w", err) - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - } - - utils.InfoLogger.Infof("Managed subscriber %s with status %s", email, status) - return nil -} - -func (c *ListmonkClient) CreateCampaign(name string, subject string, lists []int, templateID int, sendAt string) error { - payload := map[string]interface{}{ - "name": name, - "subject": subject, - "lists": lists, - "template_id": templateID, - "send_at": sendAt, - } - - jsonPayload, err := json.Marshal(payload) - if err != nil { - utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err) - return fmt.Errorf("failed to marshal payload: %w", err) - } - - resp, err := c.client.Post(c.baseURL+"/api/campaigns", "application/json", bytes.NewBuffer(jsonPayload)) - if err != nil { - utils.ErrorLogger.Errorf("Failed to create campaign: %v", err) - return fmt.Errorf("failed to create campaign: %w", err) - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - } - - utils.InfoLogger.Infof("Created campaign %s with subject %s", name, subject) - return nil -} - -================ -File: services/son_executor.go -================ -package services - -import ( - "fmt" - "time" - - "github.com/troneras/ghost-listmonk-connector/models" - "github.com/troneras/ghost-listmonk-connector/utils" -) - -type SonExecutor struct { - listmonkClient *ListmonkClient -} - -func NewSonExecutor(listmonkClient *ListmonkClient) *SonExecutor { - return &SonExecutor{ - listmonkClient: listmonkClient, - } -} - -func (e *SonExecutor) ExecuteSon(son models.Son, data map[string]interface{}) { - utils.InfoLogger.Infof("Executing Son: %s", son.ID) - - if son.Delay > 0 { - utils.InfoLogger.Infof("Delaying execution of Son %s for %v", son.ID, time.Duration(son.Delay)) - time.Sleep(time.Duration(son.Delay)) - } - - for _, action := range son.Actions { - err := e.executeAction(action, data) - if err != nil { - utils.ErrorLogger.Errorf("Error executing action for Son %s: %v", son.ID, err) - } - } - - utils.InfoLogger.Infof("Finished executing Son: %s", son.ID) -} - -func (e *SonExecutor) executeAction(action models.Action, data map[string]interface{}) error { - utils.InfoLogger.Infof("Executing action: %s", action.Type) - - switch action.Type { - case models.ActionSendTransactionalEmail: - return e.sendTransactionalEmail(action.Parameters, data) - case models.ActionManageSubscriber: - return e.manageSubscriber(action.Parameters, data) - case models.ActionCreateCampaign: - return e.createCampaign(action.Parameters, data) - default: - err := fmt.Errorf("unknown action type: %s", action.Type) - utils.ErrorLogger.Errorf("%v", err) - return err - } -} - -func (e *SonExecutor) sendTransactionalEmail(params map[string]interface{}, data map[string]interface{}) error { - templateID := int(params["template_id"].(float64)) - subscriberEmail := data["member"].(map[string]interface{})["current"].(map[string]interface{})["email"].(string) - - utils.InfoLogger.Infof("Sending transactional email to %s using template %d", subscriberEmail, templateID) - return e.listmonkClient.SendTransactionalEmail(templateID, subscriberEmail, data) -} - -func (e *SonExecutor) manageSubscriber(params map[string]interface{}, data map[string]interface{}) error { - email := data["member"].(map[string]interface{})["current"].(map[string]interface{})["email"].(string) - name := data["member"].(map[string]interface{})["current"].(map[string]interface{})["name"].(string) - status := params["status"].(string) - lists := []int{} - for _, listID := range params["lists"].([]interface{}) { - lists = append(lists, int(listID.(float64))) - } - - utils.InfoLogger.Infof("Managing subscriber %s with status %s", email, status) - return e.listmonkClient.ManageSubscriber(email, name, status, lists) -} - -func (e *SonExecutor) createCampaign(params map[string]interface{}, data map[string]interface{}) error { - name := params["name"].(string) - subject := params["subject"].(string) - lists := []int{} - for _, listID := range params["lists"].([]interface{}) { - lists = append(lists, int(listID.(float64))) - } - templateID := int(params["template_id"].(float64)) - sendAt := params["send_at"].(string) - - utils.InfoLogger.Infof("Creating campaign %s with subject %s", name, subject) - return e.listmonkClient.CreateCampaign(name, subject, lists, templateID, sendAt) -} - -================ -File: services/son_storage_test.go -================ -package services - -import ( - "os" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/troneras/ghost-listmonk-connector/models" -) - -func TestSonStorage(t *testing.T) { - // Create a temporary database for testing - tempDBPath := "test_ghost_listmonk.db" - storage, err := NewSonStorage(tempDBPath) - assert.NoError(t, err) - defer os.Remove(tempDBPath) // Clean up the temporary database file after tests - - // Test Create - son := models.Son{ - Name: "Test Son", - Trigger: models.TriggerMemberCreated, - Delay: models.Duration(5 * time.Minute), - Actions: []models.Action{ - { - Type: models.ActionSendTransactionalEmail, - Parameters: map[string]interface{}{ - "template_id": float64(1), // Use float64 to match JSON unmarshaling behavior - }, - }, - }, - } - - err = storage.Create(&son) - assert.NoError(t, err) - assert.NotEmpty(t, son.ID) // Ensure an ID was generated - - // Test Get - retrievedSon, err := storage.Get(son.ID) - assert.NoError(t, err) - assert.Equal(t, son.Name, retrievedSon.Name) - assert.Equal(t, son.Trigger, retrievedSon.Trigger) - assert.Equal(t, son.Delay, retrievedSon.Delay) - assert.Len(t, retrievedSon.Actions, 1) - assert.Equal(t, son.Actions[0].Type, retrievedSon.Actions[0].Type) - assert.Equal(t, son.Actions[0].Parameters["template_id"], retrievedSon.Actions[0].Parameters["template_id"]) - - // Test Update - son.Name = "Updated Test Son" - err = storage.Update(son) - assert.NoError(t, err) - - updatedSon, err := storage.Get(son.ID) - assert.NoError(t, err) - assert.Equal(t, "Updated Test Son", updatedSon.Name) - - // Test List - sons, err := storage.List() - assert.NoError(t, err) - assert.Len(t, sons, 1) - assert.Equal(t, son.ID, sons[0].ID) - - // Test Delete - err = storage.Delete(son.ID) - assert.NoError(t, err) - - _, err = storage.Get(son.ID) - assert.Error(t, err) - assert.Equal(t, ErrSonNotFound, err) - - // Test error cases - err = storage.Create(&son) - assert.NoError(t, err) - - // Attempt to update a non-existent son (should fail) - nonExistentSon := models.Son{ID: "non-existent", Name: "Non-existent Son"} - err = storage.Update(nonExistentSon) - assert.Equal(t, ErrSonNotFound, err) - - err = storage.Delete("non-existent") - assert.Equal(t, ErrSonNotFound, err) -} - -================ -File: services/son_storage.go -================ -package services - -import ( - "database/sql" - "encoding/json" - "errors" - "time" - - _ "github.com/mattn/go-sqlite3" - "github.com/troneras/ghost-listmonk-connector/models" - "github.com/troneras/ghost-listmonk-connector/utils" -) - -var ( - ErrSonAlreadyExists = errors.New("son with this ID already exists") - ErrSonNotFound = errors.New("son not found") -) - -type SonStorage struct { - db *sql.DB -} - -func NewSonStorage(dbPath string) (*SonStorage, error) { - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - return nil, err - } - - if err := db.Ping(); err != nil { - return nil, err - } - - storage := &SonStorage{db: db} - if err := storage.createTables(); err != nil { - return nil, err - } - - return storage, nil -} - -func (s *SonStorage) createTables() error { - _, err := s.db.Exec(` - CREATE TABLE IF NOT EXISTS sons ( - id TEXT PRIMARY KEY, - name TEXT, - trigger TEXT, - delay INTEGER, - actions TEXT, - created_at DATETIME, - updated_at DATETIME - ) - `) - return err -} - -func (s *SonStorage) Create(son *models.Son) error { - son.ID = utils.GenerateUUID() - son.CreatedAt = time.Now() - son.UpdatedAt = time.Now() - - actionsJSON, err := json.Marshal(son.Actions) - if err != nil { - return err - } - - _, err = s.db.Exec( - "INSERT INTO sons (id, name, trigger, delay, actions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - son.ID, son.Name, son.Trigger, int64(son.Delay), actionsJSON, son.CreatedAt, son.UpdatedAt, - ) - if err != nil { - utils.ErrorLogger.Errorf("Failed to create Son: %v", err) - return err - } - - utils.InfoLogger.Infof("Created new Son with ID: %s", son.ID) - return nil -} - -func (s *SonStorage) Get(id string) (models.Son, error) { - var son models.Son - var actionsJSON []byte - var delayInt int64 - - err := s.db.QueryRow( - "SELECT id, name, trigger, delay, actions, created_at, updated_at FROM sons WHERE id = ?", - id, - ).Scan(&son.ID, &son.Name, &son.Trigger, &delayInt, &actionsJSON, &son.CreatedAt, &son.UpdatedAt) - - if err != nil { - if err == sql.ErrNoRows { - utils.ErrorLogger.Errorf("Failed to get Son: %v", ErrSonNotFound) - return models.Son{}, ErrSonNotFound - } - utils.ErrorLogger.Errorf("Failed to get Son: %v", err) - return models.Son{}, err - } - - son.Delay = models.Duration(time.Duration(delayInt)) - - err = json.Unmarshal(actionsJSON, &son.Actions) - if err != nil { - utils.ErrorLogger.Errorf("Failed to unmarshal actions: %v", err) - return models.Son{}, err - } - - utils.InfoLogger.Infof("Retrieved Son with ID: %s", id) - return son, nil -} - -func (s *SonStorage) Update(son models.Son) error { - son.UpdatedAt = time.Now() - - actionsJSON, err := json.Marshal(son.Actions) - if err != nil { - return err - } - - result, err := s.db.Exec( - "UPDATE sons SET name = ?, trigger = ?, delay = ?, actions = ?, updated_at = ? WHERE id = ?", - son.Name, son.Trigger, int64(son.Delay), actionsJSON, son.UpdatedAt, son.ID, - ) - if err != nil { - utils.ErrorLogger.Errorf("Failed to update Son: %v", err) - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return err - } - - if rowsAffected == 0 { - utils.ErrorLogger.Errorf("Failed to update Son: %v", ErrSonNotFound) - return ErrSonNotFound - } - - utils.InfoLogger.Infof("Updated Son with ID: %s", son.ID) - return nil -} - -func (s *SonStorage) Delete(id string) error { - result, err := s.db.Exec("DELETE FROM sons WHERE id = ?", id) - if err != nil { - utils.ErrorLogger.Errorf("Failed to delete Son: %v", err) - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return err - } - - if rowsAffected == 0 { - utils.ErrorLogger.Errorf("Failed to delete Son: %v", ErrSonNotFound) - return ErrSonNotFound - } - - utils.InfoLogger.Infof("Deleted Son with ID: %s", id) - return nil -} - -func (s *SonStorage) List() ([]models.Son, error) { - rows, err := s.db.Query("SELECT id, name, trigger, delay, actions, created_at, updated_at FROM sons") - if err != nil { - utils.ErrorLogger.Errorf("Failed to list Sons: %v", err) - return nil, err - } - defer rows.Close() - - var sons []models.Son - for rows.Next() { - var son models.Son - var actionsJSON []byte - var delayInt int64 - - err := rows.Scan(&son.ID, &son.Name, &son.Trigger, &delayInt, &actionsJSON, &son.CreatedAt, &son.UpdatedAt) - if err != nil { - utils.ErrorLogger.Errorf("Failed to scan Son: %v", err) - continue - } - - son.Delay = models.Duration(time.Duration(delayInt)) - - err = json.Unmarshal(actionsJSON, &son.Actions) - if err != nil { - utils.ErrorLogger.Errorf("Failed to unmarshal actions: %v", err) - continue - } - - sons = append(sons, son) - } - - if sons == nil { - sons = []models.Son{} // Ensure we always return an array, even if empty - } - - utils.InfoLogger.Infof("Retrieved list of %d Sons", len(sons)) - return sons, nil -} - -================ -File: utils/config.go -================ -package utils - -import ( - "bufio" - "fmt" - "os" - "strings" -) - -// Config holds all configuration values -type Config struct { - ListmonkURL string - APIKey string - Port string - // Add other configuration fields as needed -} - -// LoadConfig reads configuration from .env.local file and environment variables -func LoadConfig() (*Config, error) { - config := &Config{} - - // Read from .env.local file - if err := loadEnvFile(".env.local", config); err != nil { - ErrorLogger.Printf("Error loading .env.local file: %v", err) - // Continue execution, as we'll fall back to environment variables - } - - // Override with environment variables if they exist - if envListmonkURL := os.Getenv("LISTMONK_URL"); envListmonkURL != "" { - config.ListmonkURL = envListmonkURL - } - if envAPIKey := os.Getenv("API_KEY"); envAPIKey != "" { - config.APIKey = envAPIKey - } - - if envPort := os.Getenv("API_KEY"); envPort != "" { - config.Port = envPort - } - - // Validate required fields - if config.ListmonkURL == "" { - return nil, fmt.Errorf("LISTMONK_URL is not set") - } - if config.APIKey == "" { - return nil, fmt.Errorf("API_KEY is not set") - } - if config.Port == "" { - config.Port = "8808" - } - - return config, nil -} - -func loadEnvFile(filename string, config *Config) error { - file, err := os.Open(filename) - if err != nil { - return err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - switch key { - case "LISTMONK_URL": - config.ListmonkURL = value - case "API_KEY": - config.APIKey = value - case "PORT": - config.Port = value - // Add other configuration fields as needed - } - } - - return scanner.Err() -} - -================ -File: utils/errors.go -================ -package utils - -import "fmt" - -type CustomError struct { - Code string - Message string -} - -func (e *CustomError) Error() string { - return fmt.Sprintf("%s: %s", e.Code, e.Message) -} - -func NewError(code, message string) *CustomError { - return &CustomError{ - Code: code, - Message: message, - } -} - -================ -File: utils/logger.go -================ -package utils - -import ( - "log" - "os" -) - -type Logger struct { - *log.Logger -} - -var ( - InfoLogger *Logger - ErrorLogger *Logger -) - -func init() { - InfoLogger = &Logger{log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)} - ErrorLogger = &Logger{log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)} -} - -func (l *Logger) Infof(format string, v ...interface{}) { - l.Printf(format, v...) -} - -func (l *Logger) Errorf(format string, v ...interface{}) { - l.Printf(format, v...) -} - -================ -File: utils/pretty_print.go -================ -package utils - -import ( - "encoding/json" -) - -func PrettyPrint(v interface{}) string { - b, err := json.MarshalIndent(v, "", " ") - if err != nil { - return "" - } - return string(b) -} - -================ -File: utils/uuid.go -================ -package utils - -import ( - "github.com/google/uuid" -) - -func GenerateUUID() string { - return uuid.New().String() -} - -================ -File: .env.local -================ -LISTMONK_URL=http://localhost:9000 -NEXT_APP_URL=http://localhost:3000 -BASIC_AUTH_USERNAME=username -BASIC_AUTH_PASSWORD=password -API_KEY=your-api-key - -================ -File: docker-compose.yml -================ -version: '3' -services: - app: - build: . - ports: - - "8808:8808" - environment: - - PORT=8808 - - LISTMONK_URL=http://listmonk:9000 - - BASIC_AUTH_USERNAME=admin - - BASIC_AUTH_PASSWORD=password - depends_on: - - listmonk - - listmonk: - image: listmonk/listmonk:latest - ports: - - "9000:9000" - environment: - - APP_DB_HOST=db - - APP_DB_USER=listmonk - - APP_DB_PASSWORD=listmonk - depends_on: - - db - - db: - image: postgres:13 - environment: - - POSTGRES_USER=listmonk - - POSTGRES_PASSWORD=listmonk - - POSTGRES_DB=listmonk - volumes: - - listmonk-data:/var/lib/postgresql/data - -volumes: - listmonk-data: - -================ -File: Dockerfile -================ -FROM golang:1.17-alpine AS builder - -WORKDIR /app - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . - -FROM alpine:latest - -RUN apk --no-cache add ca-certificates - -WORKDIR /root/ - -COPY --from=builder /app/main . - -EXPOSE 8808 - -CMD ["./main"] - -================ -File: go.mod -================ -module github.com/troneras/ghost-listmonk-connector - -go 1.21.6 - -require ( - github.com/gin-contrib/cors v1.7.2 - github.com/gin-gonic/gin v1.10.0 - github.com/google/uuid v1.6.0 - github.com/mattn/go-sqlite3 v1.14.22 - github.com/stretchr/testify v1.9.0 -) - -require ( - github.com/bytedance/sonic v1.12.1 // indirect - github.com/bytedance/sonic/loader v0.2.0 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.5 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.22.0 // indirect - github.com/goccy/go-json v0.10.3 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect - golang.org/x/arch v0.9.0 // indirect - golang.org/x/crypto v0.26.0 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sys v0.24.0 // indirect - golang.org/x/text v0.17.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -================ -File: main.go -================ -package main - -import ( - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" - "github.com/troneras/ghost-listmonk-connector/handlers" - "github.com/troneras/ghost-listmonk-connector/middleware" - "github.com/troneras/ghost-listmonk-connector/services" - "github.com/troneras/ghost-listmonk-connector/utils" -) - -func main() { - config, err := utils.LoadConfig() - if err != nil { - utils.ErrorLogger.Fatalf("Failed to load configuration: %v", err) - } - r := gin.Default() - - // CORS configuration - corsConfig := cors.DefaultConfig() - corsConfig.AllowOrigins = []string{"http://localhost:3000"} // Add your frontend URL here - corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} - corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"} - corsConfig.AllowCredentials = true - - r.Use(cors.New(corsConfig)) - - // Initialize services - sonStorage, err := services.NewSonStorage("ghost_listmonk.db") - if err != nil { - utils.ErrorLogger.Fatalf("Failed to initialize son storage: %v", err) - } - - listmonkClient := services.NewListmonkClient(config) - sonExecutor := services.NewSonExecutor(listmonkClient) - sonHandler := handlers.NewSonHandler(sonStorage) - webhookHandler := handlers.NewWebhookHandler(sonStorage, sonExecutor) - listmonkHandler := handlers.NewListmonkHandler(listmonkClient) - - // Webhook endpoint (no auth required) - r.POST("/webhook", webhookHandler.HandleWebhook) - - // Web UI routes (auth required) - authorized := r.Group("/") - authorized.Use(middleware.APIKeyAuth(config.APIKey)) - { - authorized.GET("/", handleHome) - - // Son routes - sons := authorized.Group("/sons") - { - sons.POST("", sonHandler.Create) - sons.GET("", sonHandler.List) - sons.GET("/:id", sonHandler.Get) - sons.PUT("/:id", sonHandler.Update) - sons.DELETE("/:id", sonHandler.Delete) - } - authorized.GET("/lists", listmonkHandler.GetLists) - authorized.GET("/templates", listmonkHandler.GetTemplates) - } - - utils.InfoLogger.Infof("Server starting on port %s", config.Port) - if err := r.Run(":" + config.Port); err != nil { - utils.ErrorLogger.Fatalf("Failed to start server: %v", err) - } -} - -func handleHome(c *gin.Context) { - c.JSON(200, gin.H{"message": "Welcome to the Ghost-Listmonk Connector"}) -} diff --git a/routes/routes.go b/routes/routes.go new file mode 100644 index 0000000..40cf909 --- /dev/null +++ b/routes/routes.go @@ -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) +} diff --git a/services/database.go b/services/database.go deleted file mode 100644 index 3ae8df8..0000000 --- a/services/database.go +++ /dev/null @@ -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, ¶metersJSON) - 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, ¶metersJSON) - 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 -} diff --git a/services/email_service.go b/services/email_service.go new file mode 100644 index 0000000..ce9d468 --- /dev/null +++ b/services/email_service.go @@ -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 ` + + +

Your Magic Link

+

Click the button below to log in:

+
Log In + + + ` +} diff --git a/services/listmonk_client.go b/services/listmonk_client.go index 0d4d578..e1653f0 100644 --- a/services/listmonk_client.go +++ b/services/listmonk_client.go @@ -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) +} diff --git a/services/listmonk_client_test.go b/services/listmonk_client_test.go deleted file mode 100644 index ac7b0bf..0000000 --- a/services/listmonk_client_test.go +++ /dev/null @@ -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) -} diff --git a/services/magic_link_service.go b/services/magic_link_service.go new file mode 100644 index 0000000..9139987 --- /dev/null +++ b/services/magic_link_service.go @@ -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 +} diff --git a/services/recent_activity_service.go b/services/recent_activity_service.go new file mode 100644 index 0000000..c61193e --- /dev/null +++ b/services/recent_activity_service.go @@ -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 +} diff --git a/services/services.go b/services/services.go new file mode 100644 index 0000000..3f27ad9 --- /dev/null +++ b/services/services.go @@ -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 +} diff --git a/services/son_execution_logger.go b/services/son_execution_logger.go new file mode 100644 index 0000000..abd7458 --- /dev/null +++ b/services/son_execution_logger.go @@ -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 +} diff --git a/services/son_executor.go b/services/son_executor.go index 740f426..137f5e2 100644 --- a/services/son_executor.go +++ b/services/son_executor.go @@ -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 } diff --git a/services/son_storage.go b/services/son_storage.go index 904e020..a74e875 100644 --- a/services/son_storage.go +++ b/services/son_storage.go @@ -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 } diff --git a/services/son_storage_test.go b/services/son_storage_test.go deleted file mode 100644 index a09b1e5..0000000 --- a/services/son_storage_test.go +++ /dev/null @@ -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) -} diff --git a/services/user_service.go b/services/user_service.go new file mode 100644 index 0000000..958dbf0 --- /dev/null +++ b/services/user_service.go @@ -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 +} diff --git a/services/webhook_logger_service.go b/services/webhook_logger_service.go new file mode 100644 index 0000000..0f96a59 --- /dev/null +++ b/services/webhook_logger_service.go @@ -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"` +} diff --git a/services/webhook_service.go b/services/webhook_service.go new file mode 100644 index 0000000..335a80b --- /dev/null +++ b/services/webhook_service.go @@ -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 +} diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..0a68307 --- /dev/null +++ b/start.sh @@ -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 \ No newline at end of file diff --git a/ui/README.md b/ui/README.md index c403366..a75ac52 100644 --- a/ui/README.md +++ b/ui/README.md @@ -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. diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx deleted file mode 100644 index ba249fe..0000000 --- a/ui/app/layout.tsx +++ /dev/null @@ -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 ( - - - {children} - - - - ); -} diff --git a/ui/app/page.tsx b/ui/app/page.tsx deleted file mode 100644 index cb46d5d..0000000 --- a/ui/app/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import Dashboard from "@/components/Dashboard"; - -export default function HomePage() { - return ; -} diff --git a/ui/app/sons/layout.tsx b/ui/app/sons/layout.tsx deleted file mode 100644 index 2a6f6ea..0000000 --- a/ui/app/sons/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Layout({ children }: { children: React.ReactNode }) { - return
{children}
; -} diff --git a/ui/app/sons/new/page.tsx b/ui/app/sons/new/page.tsx deleted file mode 100644 index 49fcb3f..0000000 --- a/ui/app/sons/new/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import SonCreationForm from "@/components/SonCreationForm"; - -export default function NewSonPage() { - return ( -
-

Create New Son

- -
- ); -} diff --git a/ui/components.json b/ui/components.json index 15f2b02..0dba724 100644 --- a/ui/components.json +++ b/ui/components.json @@ -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": "" }, diff --git a/ui/components/ActionForm.tsx b/ui/components/ActionForm.tsx index 92e3cbe..c1c97f8 100644 --- a/ui/components/ActionForm.tsx +++ b/ui/components/ActionForm.tsx @@ -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; + 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 ( @@ -55,7 +64,7 @@ export function ActionForm({ Action Type - {actionType === "manage_subscriber" && ( - Subscriber management automatically adds or removes - subscribers on listmonk and adds to lists based on the trigger - event and newsletters from ghost blog. +

+ This action will automatically create the subscriber on + listmonk if he doesn't exist already. +

+ Subscribers will be automatically added or removed to lists + with the same name as the newsletters on Ghost.
)} + +
)} /> @@ -99,7 +112,7 @@ export function ActionForm({ /> )} - {actionType === "manage_subscriber" && trigger == "member_created" && ( + {actionType === "manage_subscriber" && trigger === "member_created" && ( ( - - Parameters - -