mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 15:46:41 +02:00
refactor(services): rename Go services, remove -go suffix
mana-search-go → mana-search mana-notify-go → mana-notify mana-crawler-go → mana-crawler mana-api-gateway-go → mana-api-gateway Legacy NestJS versions are deleted, suffix no longer needed. Updated all references in docker-compose, CLAUDE.md, package.json, Forgejo workflows, and service package.json files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79080d6654
commit
7e931b1c6d
90 changed files with 41 additions and 38 deletions
24
services/mana-notify/internal/handler/common.go
Normal file
24
services/mana-notify/internal/handler/common.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{
|
||||
"success": false,
|
||||
"error": map[string]any{
|
||||
"statusCode": status,
|
||||
"message": message,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
121
services/mana-notify/internal/handler/devices.go
Normal file
121
services/mana-notify/internal/handler/devices.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/manacore/mana-notify/internal/auth"
|
||||
"github.com/manacore/mana-notify/internal/db"
|
||||
)
|
||||
|
||||
type DevicesHandler struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewDevicesHandler(database *db.DB) *DevicesHandler {
|
||||
return &DevicesHandler{db: database}
|
||||
}
|
||||
|
||||
// Register handles POST /api/v1/devices/register
|
||||
func (h *DevicesHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.GetUser(r)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PushToken string `json:"pushToken"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
AppID string `json:"appId,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.PushToken == "" {
|
||||
writeError(w, http.StatusBadRequest, "pushToken is required")
|
||||
return
|
||||
}
|
||||
|
||||
tokenType := req.TokenType
|
||||
if tokenType == "" {
|
||||
tokenType = "expo"
|
||||
}
|
||||
|
||||
// Upsert: transfer ownership if token exists for different user
|
||||
var id string
|
||||
err := h.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO notify.devices (user_id, push_token, token_type, platform, device_name, app_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (push_token) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id,
|
||||
is_active = true,
|
||||
last_seen_at = NOW(),
|
||||
updated_at = NOW()
|
||||
RETURNING id`,
|
||||
user.UserID, req.PushToken, tokenType, nilIfEmpty(req.Platform), nilIfEmpty(req.DeviceName), nilIfEmpty(req.AppID),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to register device")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"device": map[string]any{"id": id}})
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/devices
|
||||
func (h *DevicesHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.GetUser(r)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.Pool.Query(r.Context(),
|
||||
`SELECT id, user_id, push_token, token_type, platform, device_name, app_id, is_active, last_seen_at, created_at, updated_at
|
||||
FROM notify.devices WHERE user_id = $1 AND is_active = true ORDER BY created_at DESC`, user.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list devices")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []db.Device
|
||||
for rows.Next() {
|
||||
var d db.Device
|
||||
if err := rows.Scan(&d.ID, &d.UserID, &d.PushToken, &d.TokenType, &d.Platform,
|
||||
&d.DeviceName, &d.AppID, &d.IsActive, &d.LastSeenAt, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
devices = append(devices, d)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/devices/{id}
|
||||
func (h *DevicesHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.GetUser(r)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
result, err := h.db.Pool.Exec(r.Context(),
|
||||
`UPDATE notify.devices SET is_active = false, updated_at = NOW() WHERE id = $1 AND user_id = $2`, id, user.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete device")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
writeError(w, http.StatusNotFound, "device not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
38
services/mana-notify/internal/handler/health.go
Normal file
38
services/mana-notify/internal/handler/health.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-notify/internal/db"
|
||||
)
|
||||
|
||||
type HealthHandler struct {
|
||||
db *db.DB
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
func NewHealthHandler(database *db.DB) *HealthHandler {
|
||||
return &HealthHandler{db: database, startTime: time.Now()}
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||
dbOK := h.db.HealthCheck(r.Context()) == nil
|
||||
|
||||
status := "healthy"
|
||||
if !dbOK {
|
||||
status = "unhealthy"
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": status,
|
||||
"version": "1.0.0",
|
||||
"service": "mana-notify",
|
||||
"uptime": time.Since(h.startTime).Seconds(),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"services": map[string]any{
|
||||
"database": dbOK,
|
||||
"redis": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
504
services/mana-notify/internal/handler/notifications.go
Normal file
504
services/mana-notify/internal/handler/notifications.go
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-notify/internal/db"
|
||||
"github.com/manacore/mana-notify/internal/queue"
|
||||
tmpl "github.com/manacore/mana-notify/internal/template"
|
||||
)
|
||||
|
||||
type NotificationsHandler struct {
|
||||
db *db.DB
|
||||
pool *queue.WorkerPool
|
||||
engine *tmpl.Engine
|
||||
}
|
||||
|
||||
func NewNotificationsHandler(database *db.DB, pool *queue.WorkerPool, engine *tmpl.Engine) *NotificationsHandler {
|
||||
return &NotificationsHandler{db: database, pool: pool, engine: engine}
|
||||
}
|
||||
|
||||
type SendRequest struct {
|
||||
Channel string `json:"channel"`
|
||||
AppID string `json:"appId"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Recipient string `json:"recipient,omitempty"`
|
||||
Recipients []string `json:"recipients,omitempty"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
|
||||
EmailOptions *EmailOptions `json:"emailOptions,omitempty"`
|
||||
PushOptions *PushOptions `json:"pushOptions,omitempty"`
|
||||
WebhookOptions *WebhookOptions `json:"webhookOptions,omitempty"`
|
||||
MatrixOptions *MatrixOptions `json:"matrixOptions,omitempty"`
|
||||
}
|
||||
|
||||
type EmailOptions struct {
|
||||
From string `json:"from,omitempty"`
|
||||
ReplyTo string `json:"replyTo,omitempty"`
|
||||
}
|
||||
|
||||
type PushOptions struct {
|
||||
Sound string `json:"sound,omitempty"`
|
||||
Badge *int `json:"badge,omitempty"`
|
||||
ChannelID string `json:"channelId,omitempty"`
|
||||
}
|
||||
|
||||
type WebhookOptions struct {
|
||||
Method string `json:"method,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
type MatrixOptions struct {
|
||||
MsgType string `json:"msgtype,omitempty"`
|
||||
FormattedBody string `json:"formattedBody,omitempty"`
|
||||
}
|
||||
|
||||
type ScheduleRequest struct {
|
||||
SendRequest
|
||||
ScheduledFor string `json:"scheduledFor"`
|
||||
}
|
||||
|
||||
type BatchRequest struct {
|
||||
Notifications []SendRequest `json:"notifications"`
|
||||
}
|
||||
|
||||
// Send handles POST /api/v1/notifications/send
|
||||
func (h *NotificationsHandler) Send(w http.ResponseWriter, r *http.Request) {
|
||||
var req SendRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateSendRequest(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check idempotency
|
||||
if req.ExternalID != "" {
|
||||
var existingID string
|
||||
err := h.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id FROM notify.notifications WHERE external_id = $1`, req.ExternalID,
|
||||
).Scan(&existingID)
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"notification": map[string]any{"id": existingID, "status": "existing"},
|
||||
"deduplicated": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check user preferences
|
||||
if req.UserID != "" {
|
||||
blocked, reason := h.checkPreferences(r.Context(), req.UserID, req.Channel)
|
||||
if blocked {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"notification": map[string]any{"status": "cancelled", "reason": reason},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Render template
|
||||
subject := req.Subject
|
||||
body := req.Body
|
||||
if req.Template != "" {
|
||||
rendered, err := h.engine.RenderBySlug(r.Context(), req.Template, req.Data, "")
|
||||
if err != nil {
|
||||
slog.Warn("template render failed", "template", req.Template, "error", err)
|
||||
} else {
|
||||
if rendered.Subject != "" {
|
||||
subject = rendered.Subject
|
||||
}
|
||||
if rendered.Body != "" {
|
||||
body = rendered.Body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priority := req.Priority
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
|
||||
// Create notification record
|
||||
var notificationID string
|
||||
err := h.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO notify.notifications (user_id, app_id, channel, template_id, subject, body, data, priority, recipient, external_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id`,
|
||||
nilIfEmpty(req.UserID), req.AppID, req.Channel, nilIfEmpty(req.Template),
|
||||
nilIfEmpty(subject), nilIfEmpty(body), jsonOrNil(req.Data),
|
||||
priority, nilIfEmpty(req.Recipient), nilIfEmpty(req.ExternalID),
|
||||
).Scan(¬ificationID)
|
||||
if err != nil {
|
||||
slog.Error("create notification failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create notification")
|
||||
return
|
||||
}
|
||||
|
||||
// Build and enqueue job
|
||||
job := queue.Job{
|
||||
NotificationID: notificationID,
|
||||
Channel: req.Channel,
|
||||
AppID: req.AppID,
|
||||
Recipient: req.Recipient,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
Data: req.Data,
|
||||
}
|
||||
|
||||
if req.EmailOptions != nil {
|
||||
job.From = req.EmailOptions.From
|
||||
job.ReplyTo = req.EmailOptions.ReplyTo
|
||||
}
|
||||
if req.PushOptions != nil {
|
||||
job.Sound = req.PushOptions.Sound
|
||||
job.Badge = req.PushOptions.Badge
|
||||
}
|
||||
if req.MatrixOptions != nil {
|
||||
job.RoomID = req.Recipient
|
||||
job.MsgType = req.MatrixOptions.MsgType
|
||||
job.FormattedBody = req.MatrixOptions.FormattedBody
|
||||
}
|
||||
if req.WebhookOptions != nil {
|
||||
job.WebhookMethod = req.WebhookOptions.Method
|
||||
job.WebhookHeaders = req.WebhookOptions.Headers
|
||||
job.WebhookTimeout = req.WebhookOptions.Timeout
|
||||
}
|
||||
if req.Channel == "matrix" {
|
||||
job.RoomID = req.Recipient
|
||||
}
|
||||
|
||||
h.pool.Enqueue(job)
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"notification": map[string]any{
|
||||
"id": notificationID,
|
||||
"status": "pending",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Schedule handles POST /api/v1/notifications/schedule
|
||||
func (h *NotificationsHandler) Schedule(w http.ResponseWriter, r *http.Request) {
|
||||
var req ScheduleRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
scheduledFor, err := time.Parse(time.RFC3339, req.ScheduledFor)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "scheduledFor must be a valid RFC3339 timestamp")
|
||||
return
|
||||
}
|
||||
if scheduledFor.Before(time.Now()) {
|
||||
writeError(w, http.StatusBadRequest, "scheduledFor must be in the future")
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateSendRequest(&req.SendRequest); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Render template
|
||||
subject := req.Subject
|
||||
body := req.Body
|
||||
if req.Template != "" {
|
||||
rendered, err := h.engine.RenderBySlug(r.Context(), req.Template, req.Data, "")
|
||||
if err == nil {
|
||||
if rendered.Subject != "" {
|
||||
subject = rendered.Subject
|
||||
}
|
||||
if rendered.Body != "" {
|
||||
body = rendered.Body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priority := req.Priority
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
|
||||
var notificationID string
|
||||
err = h.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO notify.notifications (user_id, app_id, channel, template_id, subject, body, data, priority, recipient, external_id, scheduled_for)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`,
|
||||
nilIfEmpty(req.UserID), req.AppID, req.Channel, nilIfEmpty(req.Template),
|
||||
nilIfEmpty(subject), nilIfEmpty(body), jsonOrNil(req.Data),
|
||||
priority, nilIfEmpty(req.Recipient), nilIfEmpty(req.ExternalID), scheduledFor,
|
||||
).Scan(¬ificationID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create notification")
|
||||
return
|
||||
}
|
||||
|
||||
job := queue.Job{
|
||||
NotificationID: notificationID,
|
||||
Channel: req.Channel,
|
||||
AppID: req.AppID,
|
||||
Recipient: req.Recipient,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
Data: req.Data,
|
||||
ScheduleAt: &scheduledFor,
|
||||
}
|
||||
h.pool.Enqueue(job)
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"notification": map[string]any{
|
||||
"id": notificationID,
|
||||
"status": "pending",
|
||||
"scheduledFor": scheduledFor.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Batch handles POST /api/v1/notifications/batch
|
||||
func (h *NotificationsHandler) Batch(w http.ResponseWriter, r *http.Request) {
|
||||
var req BatchRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 5<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Notifications) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "notifications array is required")
|
||||
return
|
||||
}
|
||||
if len(req.Notifications) > 100 {
|
||||
writeError(w, http.StatusBadRequest, "maximum 100 notifications per batch")
|
||||
return
|
||||
}
|
||||
|
||||
type batchResult struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
results := make([]batchResult, len(req.Notifications))
|
||||
succeeded := 0
|
||||
failed := 0
|
||||
|
||||
for i, n := range req.Notifications {
|
||||
if err := validateSendRequest(&n); err != nil {
|
||||
results[i] = batchResult{Status: "failed", Error: err.Error()}
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
subject := n.Subject
|
||||
body := n.Body
|
||||
if n.Template != "" {
|
||||
rendered, err := h.engine.RenderBySlug(r.Context(), n.Template, n.Data, "")
|
||||
if err == nil {
|
||||
if rendered.Subject != "" {
|
||||
subject = rendered.Subject
|
||||
}
|
||||
if rendered.Body != "" {
|
||||
body = rendered.Body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priority := n.Priority
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
|
||||
var notificationID string
|
||||
err := h.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO notify.notifications (user_id, app_id, channel, subject, body, data, priority, recipient)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id`,
|
||||
nilIfEmpty(n.UserID), n.AppID, n.Channel,
|
||||
nilIfEmpty(subject), nilIfEmpty(body), jsonOrNil(n.Data), priority, nilIfEmpty(n.Recipient),
|
||||
).Scan(¬ificationID)
|
||||
if err != nil {
|
||||
results[i] = batchResult{Status: "failed", Error: "database error"}
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
h.pool.Enqueue(queue.Job{
|
||||
NotificationID: notificationID,
|
||||
Channel: n.Channel,
|
||||
AppID: n.AppID,
|
||||
Recipient: n.Recipient,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
Data: n.Data,
|
||||
})
|
||||
|
||||
results[i] = batchResult{ID: notificationID, Status: "pending"}
|
||||
succeeded++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"results": results,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
})
|
||||
}
|
||||
|
||||
// GetNotification handles GET /api/v1/notifications/{id}
|
||||
func (h *NotificationsHandler) GetNotification(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "notification id required")
|
||||
return
|
||||
}
|
||||
|
||||
var n db.Notification
|
||||
err := h.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id, user_id, app_id, channel, template_id, subject, body, status, priority, scheduled_for, recipient, external_id, attempts, delivered_at, error_message, created_at, updated_at
|
||||
FROM notify.notifications WHERE id = $1`, id,
|
||||
).Scan(&n.ID, &n.UserID, &n.AppID, &n.Channel, &n.TemplateID, &n.Subject, &n.Body,
|
||||
&n.Status, &n.Priority, &n.ScheduledFor, &n.Recipient, &n.ExternalID,
|
||||
&n.Attempts, &n.DeliveredAt, &n.ErrorMessage, &n.CreatedAt, &n.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "notification not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notification": n})
|
||||
}
|
||||
|
||||
// CancelNotification handles DELETE /api/v1/notifications/{id}
|
||||
func (h *NotificationsHandler) CancelNotification(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "notification id required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.db.Pool.Exec(r.Context(),
|
||||
`UPDATE notify.notifications SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status = 'pending'`, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to cancel notification")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
writeError(w, http.StatusNotFound, "notification not found or already processed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cancelled": true})
|
||||
}
|
||||
|
||||
func (h *NotificationsHandler) checkPreferences(ctx context.Context, userID, ch string) (bool, string) {
|
||||
var emailEnabled, pushEnabled, quietEnabled bool
|
||||
var quietStart, quietEnd, timezone *string
|
||||
|
||||
err := h.db.Pool.QueryRow(ctx,
|
||||
`SELECT email_enabled, push_enabled, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone
|
||||
FROM notify.preferences WHERE user_id = $1`, userID,
|
||||
).Scan(&emailEnabled, &pushEnabled, &quietEnabled, &quietStart, &quietEnd, &timezone)
|
||||
|
||||
if err != nil {
|
||||
return false, "" // No preferences = allow
|
||||
}
|
||||
|
||||
// Check channel preferences
|
||||
if ch == "email" && !emailEnabled {
|
||||
return true, "email notifications disabled by user"
|
||||
}
|
||||
if ch == "push" && !pushEnabled {
|
||||
return true, "push notifications disabled by user"
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
if quietEnabled && quietStart != nil && quietEnd != nil {
|
||||
tz := "Europe/Berlin"
|
||||
if timezone != nil {
|
||||
tz = *timezone
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err == nil {
|
||||
now := time.Now().In(loc)
|
||||
nowMinutes := now.Hour()*60 + now.Minute()
|
||||
|
||||
startH, startM := parseTime(*quietStart)
|
||||
endH, endM := parseTime(*quietEnd)
|
||||
startMinutes := startH*60 + startM
|
||||
endMinutes := endH*60 + endM
|
||||
|
||||
var inQuiet bool
|
||||
if startMinutes <= endMinutes {
|
||||
inQuiet = nowMinutes >= startMinutes && nowMinutes < endMinutes
|
||||
} else {
|
||||
// Spans midnight (e.g. 22:00 to 08:00)
|
||||
inQuiet = nowMinutes >= startMinutes || nowMinutes < endMinutes
|
||||
}
|
||||
|
||||
if inQuiet {
|
||||
return true, "quiet hours active"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func validateSendRequest(req *SendRequest) error {
|
||||
if req.Channel == "" {
|
||||
return fmt.Errorf("channel is required")
|
||||
}
|
||||
validChannels := map[string]bool{"email": true, "push": true, "matrix": true, "webhook": true}
|
||||
if !validChannels[req.Channel] {
|
||||
return fmt.Errorf("channel must be email, push, matrix, or webhook")
|
||||
}
|
||||
if req.AppID == "" {
|
||||
return fmt.Errorf("appId is required")
|
||||
}
|
||||
if req.Recipient == "" && len(req.Recipients) == 0 && req.UserID == "" {
|
||||
return fmt.Errorf("recipient, recipients, or userId is required")
|
||||
}
|
||||
if req.Template == "" && req.Body == "" {
|
||||
return fmt.Errorf("template or body is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTime(s string) (int, int) {
|
||||
var h, m int
|
||||
fmt.Sscanf(s, "%d:%d", &h, &m)
|
||||
return h, m
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func jsonOrNil(data map[string]any) []byte {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
200
services/mana-notify/internal/handler/notifications_test.go
Normal file
200
services/mana-notify/internal/handler/notifications_test.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateSendRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req SendRequest
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing channel",
|
||||
req: SendRequest{AppID: "app1", Recipient: "user@test.com", Body: "hello"},
|
||||
wantErr: "channel is required",
|
||||
},
|
||||
{
|
||||
name: "invalid channel",
|
||||
req: SendRequest{Channel: "sms", AppID: "app1", Recipient: "user@test.com", Body: "hello"},
|
||||
wantErr: "channel must be email, push, matrix, or webhook",
|
||||
},
|
||||
{
|
||||
name: "missing appId",
|
||||
req: SendRequest{Channel: "email", Recipient: "user@test.com", Body: "hello"},
|
||||
wantErr: "appId is required",
|
||||
},
|
||||
{
|
||||
name: "missing recipient and userId",
|
||||
req: SendRequest{Channel: "email", AppID: "app1", Body: "hello"},
|
||||
wantErr: "recipient, recipients, or userId is required",
|
||||
},
|
||||
{
|
||||
name: "missing template and body",
|
||||
req: SendRequest{Channel: "email", AppID: "app1", Recipient: "user@test.com"},
|
||||
wantErr: "template or body is required",
|
||||
},
|
||||
{
|
||||
name: "valid with recipient and body",
|
||||
req: SendRequest{Channel: "email", AppID: "app1", Recipient: "user@test.com", Body: "hello"},
|
||||
},
|
||||
{
|
||||
name: "valid with userId and template",
|
||||
req: SendRequest{Channel: "push", AppID: "app1", UserID: "u1", Template: "welcome"},
|
||||
},
|
||||
{
|
||||
name: "valid with recipients",
|
||||
req: SendRequest{Channel: "webhook", AppID: "app1", Recipients: []string{"url1"}, Body: "data"},
|
||||
},
|
||||
{
|
||||
name: "valid email channel",
|
||||
req: SendRequest{Channel: "email", AppID: "app1", Recipient: "a@b.com", Body: "hi"},
|
||||
},
|
||||
{
|
||||
name: "valid push channel",
|
||||
req: SendRequest{Channel: "push", AppID: "app1", Recipient: "token", Body: "hi"},
|
||||
},
|
||||
{
|
||||
name: "valid matrix channel",
|
||||
req: SendRequest{Channel: "matrix", AppID: "app1", Recipient: "!room:server", Body: "hi"},
|
||||
},
|
||||
{
|
||||
name: "valid webhook channel",
|
||||
req: SendRequest{Channel: "webhook", AppID: "app1", Recipient: "https://hook.example.com", Body: "{}"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateSendRequest(&tt.req)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error %q, got nil", tt.wantErr)
|
||||
}
|
||||
if err.Error() != tt.wantErr {
|
||||
t.Fatalf("expected error %q, got %q", tt.wantErr, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantH int
|
||||
wantM int
|
||||
}{
|
||||
{"22:00", 22, 0},
|
||||
{"08:30", 8, 30},
|
||||
{"0:00", 0, 0},
|
||||
{"23:59", 23, 59},
|
||||
{"invalid", 0, 0},
|
||||
{"", 0, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
h, m := parseTime(tt.input)
|
||||
if h != tt.wantH || m != tt.wantM {
|
||||
t.Fatalf("parseTime(%q) = (%d, %d), want (%d, %d)", tt.input, h, m, tt.wantH, tt.wantM)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
isNil bool
|
||||
}{
|
||||
{"empty string returns nil", "", true},
|
||||
{"non-empty returns pointer", "hello", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := nilIfEmpty(tt.input)
|
||||
if tt.isNil {
|
||||
if result != nil {
|
||||
t.Fatal("expected nil, got non-nil")
|
||||
}
|
||||
} else {
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil, got nil")
|
||||
}
|
||||
if *result != tt.input {
|
||||
t.Fatalf("expected %q, got %q", tt.input, *result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonOrNil(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]any
|
||||
isNil bool
|
||||
verify func(t *testing.T, b []byte)
|
||||
}{
|
||||
{
|
||||
name: "nil map returns nil",
|
||||
input: nil,
|
||||
isNil: true,
|
||||
},
|
||||
{
|
||||
name: "empty map returns valid JSON",
|
||||
input: map[string]any{},
|
||||
isNil: false,
|
||||
verify: func(t *testing.T, b []byte) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("expected empty map, got %v", m)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "map with data returns valid JSON",
|
||||
input: map[string]any{"key": "value", "num": float64(42)},
|
||||
isNil: false,
|
||||
verify: func(t *testing.T, b []byte) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if m["key"] != "value" {
|
||||
t.Fatalf("expected key=value, got %v", m["key"])
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := jsonOrNil(tt.input)
|
||||
if tt.isNil {
|
||||
if result != nil {
|
||||
t.Fatal("expected nil, got non-nil")
|
||||
}
|
||||
} else {
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil, got nil")
|
||||
}
|
||||
if tt.verify != nil {
|
||||
tt.verify(t, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
95
services/mana-notify/internal/handler/preferences.go
Normal file
95
services/mana-notify/internal/handler/preferences.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/manacore/mana-notify/internal/auth"
|
||||
"github.com/manacore/mana-notify/internal/db"
|
||||
)
|
||||
|
||||
type PreferencesHandler struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewPreferencesHandler(database *db.DB) *PreferencesHandler {
|
||||
return &PreferencesHandler{db: database}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/preferences
|
||||
func (h *PreferencesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.GetUser(r)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var p db.Preference
|
||||
err := h.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id, user_id, email_enabled, push_enabled, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone, category_preferences, created_at, updated_at
|
||||
FROM notify.preferences WHERE user_id = $1`, user.UserID,
|
||||
).Scan(&p.ID, &p.UserID, &p.EmailEnabled, &p.PushEnabled, &p.QuietHoursEnabled,
|
||||
&p.QuietHoursStart, &p.QuietHoursEnd, &p.Timezone, &p.CategoryPreferences, &p.CreatedAt, &p.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
// Return defaults
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"preferences": map[string]any{
|
||||
"emailEnabled": false,
|
||||
"pushEnabled": true,
|
||||
"quietHoursEnabled": false,
|
||||
"timezone": "Europe/Berlin",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"preferences": p})
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/preferences
|
||||
func (h *PreferencesHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.GetUser(r)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
EmailEnabled *bool `json:"emailEnabled,omitempty"`
|
||||
PushEnabled *bool `json:"pushEnabled,omitempty"`
|
||||
QuietHoursEnabled *bool `json:"quietHoursEnabled,omitempty"`
|
||||
QuietHoursStart *string `json:"quietHoursStart,omitempty"`
|
||||
QuietHoursEnd *string `json:"quietHoursEnd,omitempty"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
CategoryPreferences map[string]any `json:"categoryPreferences,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
catJSON, _ := json.Marshal(req.CategoryPreferences)
|
||||
|
||||
_, err := h.db.Pool.Exec(r.Context(),
|
||||
`INSERT INTO notify.preferences (user_id, email_enabled, push_enabled, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone, category_preferences)
|
||||
VALUES ($1, COALESCE($2, false), COALESCE($3, true), COALESCE($4, false), $5, $6, COALESCE($7, 'Europe/Berlin'), $8)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
email_enabled = COALESCE($2, notify.preferences.email_enabled),
|
||||
push_enabled = COALESCE($3, notify.preferences.push_enabled),
|
||||
quiet_hours_enabled = COALESCE($4, notify.preferences.quiet_hours_enabled),
|
||||
quiet_hours_start = COALESCE($5, notify.preferences.quiet_hours_start),
|
||||
quiet_hours_end = COALESCE($6, notify.preferences.quiet_hours_end),
|
||||
timezone = COALESCE($7, notify.preferences.timezone),
|
||||
category_preferences = COALESCE($8, notify.preferences.category_preferences),
|
||||
updated_at = NOW()`,
|
||||
user.UserID, req.EmailEnabled, req.PushEnabled, req.QuietHoursEnabled,
|
||||
req.QuietHoursStart, req.QuietHoursEnd, req.Timezone, catJSON,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update preferences")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"updated": true})
|
||||
}
|
||||
213
services/mana-notify/internal/handler/templates.go
Normal file
213
services/mana-notify/internal/handler/templates.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/manacore/mana-notify/internal/db"
|
||||
tmpl "github.com/manacore/mana-notify/internal/template"
|
||||
)
|
||||
|
||||
type TemplatesHandler struct {
|
||||
db *db.DB
|
||||
engine *tmpl.Engine
|
||||
}
|
||||
|
||||
func NewTemplatesHandler(database *db.DB, engine *tmpl.Engine) *TemplatesHandler {
|
||||
return &TemplatesHandler{db: database, engine: engine}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/templates
|
||||
func (h *TemplatesHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.db.Pool.Query(r.Context(),
|
||||
`SELECT id, slug, app_id, channel, subject, body_template, locale, is_active, is_system, variables, created_at, updated_at
|
||||
FROM notify.templates ORDER BY slug`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list templates")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var templates []db.Template
|
||||
for rows.Next() {
|
||||
var t db.Template
|
||||
if err := rows.Scan(&t.ID, &t.Slug, &t.AppID, &t.Channel, &t.Subject, &t.BodyTemplate,
|
||||
&t.Locale, &t.IsActive, &t.IsSystem, &t.Variables, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
templates = append(templates, t)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": templates})
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/templates/{slug}
|
||||
func (h *TemplatesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
locale := r.URL.Query().Get("locale")
|
||||
if locale == "" {
|
||||
locale = "de-DE"
|
||||
}
|
||||
|
||||
var t db.Template
|
||||
err := h.db.Pool.QueryRow(r.Context(),
|
||||
`SELECT id, slug, app_id, channel, subject, body_template, locale, is_active, is_system, variables, created_at, updated_at
|
||||
FROM notify.templates WHERE slug = $1 AND locale = $2`, slug, locale,
|
||||
).Scan(&t.ID, &t.Slug, &t.AppID, &t.Channel, &t.Subject, &t.BodyTemplate,
|
||||
&t.Locale, &t.IsActive, &t.IsSystem, &t.Variables, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "template not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"template": t})
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/templates
|
||||
func (h *TemplatesHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Slug string `json:"slug"`
|
||||
AppID string `json:"appId,omitempty"`
|
||||
Channel string `json:"channel"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
BodyTemplate string `json:"bodyTemplate"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Variables any `json:"variables,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Slug == "" || req.Channel == "" || req.BodyTemplate == "" {
|
||||
writeError(w, http.StatusBadRequest, "slug, channel, and bodyTemplate are required")
|
||||
return
|
||||
}
|
||||
if req.Locale == "" {
|
||||
req.Locale = "de-DE"
|
||||
}
|
||||
|
||||
varsJSON, _ := json.Marshal(req.Variables)
|
||||
|
||||
var id string
|
||||
err := h.db.Pool.QueryRow(r.Context(),
|
||||
`INSERT INTO notify.templates (slug, app_id, channel, subject, body_template, locale, variables)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
req.Slug, nilIfEmpty(req.AppID), req.Channel, nilIfEmpty(req.Subject), req.BodyTemplate, req.Locale, varsJSON,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "template already exists for this slug+locale")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/templates/{slug}
|
||||
func (h *TemplatesHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
locale := r.URL.Query().Get("locale")
|
||||
if locale == "" {
|
||||
locale = "de-DE"
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
BodyTemplate string `json:"bodyTemplate,omitempty"`
|
||||
IsActive *bool `json:"isActive,omitempty"`
|
||||
Variables any `json:"variables,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.db.Pool.Exec(r.Context(),
|
||||
`UPDATE notify.templates SET
|
||||
subject = COALESCE($1, subject),
|
||||
body_template = COALESCE($2, body_template),
|
||||
is_active = COALESCE($3, is_active),
|
||||
updated_at = NOW()
|
||||
WHERE slug = $4 AND locale = $5 AND is_system = false`,
|
||||
nilIfEmpty(req.Subject), nilIfEmpty(req.BodyTemplate), req.IsActive, slug, locale,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update template")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
writeError(w, http.StatusNotFound, "template not found or is a system template")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"updated": true})
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/templates/{slug}
|
||||
func (h *TemplatesHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
|
||||
result, err := h.db.Pool.Exec(r.Context(),
|
||||
`DELETE FROM notify.templates WHERE slug = $1 AND is_system = false`, slug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete template")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
writeError(w, http.StatusNotFound, "template not found or is a system template")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
|
||||
// Preview handles POST /api/v1/templates/{slug}/preview
|
||||
func (h *TemplatesHandler) Preview(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
var req struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
rendered, err := h.engine.RenderBySlug(r.Context(), slug, req.Data, "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "template not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"subject": rendered.Subject, "body": rendered.Body})
|
||||
}
|
||||
|
||||
// PreviewCustom handles POST /api/v1/templates/preview
|
||||
func (h *TemplatesHandler) PreviewCustom(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
BodyTemplate string `json:"bodyTemplate"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
subject := ""
|
||||
if req.Subject != "" {
|
||||
s, err := tmpl.RenderDirect(req.Subject, req.Data)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid subject template: "+err.Error())
|
||||
return
|
||||
}
|
||||
subject = s
|
||||
}
|
||||
|
||||
body, err := tmpl.RenderDirect(req.BodyTemplate, req.Data)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body template: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"subject": subject, "body": body})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue