feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file, render, notification, gateway (Go) services with vendored deps, plus DB migrations, event/API contracts, and an init-db script. Wire the Next.js frontend to the gateway: server-side JWT auth routes (login/register/refresh/logout/me), gateway fetch helper, and session/ cookie/jwt helpers under src/lib. Containerize the stack via docker-compose.v2.yml and per-service Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via next/font/local to avoid Google Fonts (geo-blocked). Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/flatrender/notification-svc/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListNotifications(ctx context.Context, userID uuid.UUID, onlyUnread bool, page, pageSize int) ([]*models.Notification, int64, error) {
|
||||
where := "user_id = $1 AND deleted_at IS NULL"
|
||||
args := []any{userID}
|
||||
if onlyUnread {
|
||||
where += " AND seen = FALSE"
|
||||
}
|
||||
|
||||
var total int64
|
||||
_ = s.pool.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM notification.notifications WHERE %s", where), args...).Scan(&total)
|
||||
|
||||
args = append(args, pageSize, (page-1)*pageSize)
|
||||
rows, err := s.pool.Query(ctx,
|
||||
fmt.Sprintf(`SELECT id, tenant_id, user_id,
|
||||
notification_type::text, priority::text,
|
||||
title, message, label, signature, icon, image, animation_demo, design,
|
||||
action_url, action_text,
|
||||
render_job_id, export_id, payment_id, gift_id, earned_gift_id,
|
||||
is_emergency, seen, seen_at, clicked, clicked_at, gift_used,
|
||||
expire_date, created_at, updated_at
|
||||
FROM notification.notifications
|
||||
WHERE %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $%d OFFSET $%d`, where, len(args)-1, len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
notifs, err := scanNotifications(rows)
|
||||
return notifs, total, err
|
||||
}
|
||||
|
||||
func (s *Store) GetNotificationByID(ctx context.Context, id, userID uuid.UUID) (*models.Notification, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, user_id,
|
||||
notification_type::text, priority::text,
|
||||
title, message, label, signature, icon, image, animation_demo, design,
|
||||
action_url, action_text,
|
||||
render_job_id, export_id, payment_id, gift_id, earned_gift_id,
|
||||
is_emergency, seen, seen_at, clicked, clicked_at, gift_used,
|
||||
expire_date, created_at, updated_at
|
||||
FROM notification.notifications
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, id, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
notifs, err := scanNotifications(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(notifs) == 0 {
|
||||
return nil, fmt.Errorf("notification not found")
|
||||
}
|
||||
return notifs[0], nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateNotification(ctx context.Context, req *models.CreateNotificationRequest) (*models.Notification, error) {
|
||||
priority := "Normal"
|
||||
if req.Priority != nil {
|
||||
priority = *req.Priority
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO notification.notifications
|
||||
(tenant_id, user_id, notification_type, priority, title, message, label,
|
||||
icon, image, action_url, action_text,
|
||||
render_job_id, export_id, payment_id, gift_id, earned_gift_id,
|
||||
is_emergency, expire_date)
|
||||
VALUES ($1,$2,$3::notification_kind,$4::notification_priority,$5,$6,$7,
|
||||
$8,$9,$10,$11,
|
||||
$12,$13,$14,$15,$16,
|
||||
$17,$18)
|
||||
RETURNING id`,
|
||||
req.TenantID, req.UserID, req.NotificationType, priority,
|
||||
req.Title, req.Message, req.Label,
|
||||
req.Icon, req.Image, req.ActionURL, req.ActionText,
|
||||
req.RenderJobID, req.ExportID, req.PaymentID, req.GiftID, req.EarnedGiftID,
|
||||
req.IsEmergency, req.ExpireDate,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetNotificationByID(ctx, id, req.UserID)
|
||||
}
|
||||
|
||||
func (s *Store) MarkSeen(ctx context.Context, id, userID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE notification.notifications SET seen = TRUE, seen_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND seen = FALSE`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkAllSeen(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE notification.notifications SET seen = TRUE, seen_at = NOW(), updated_at = NOW()
|
||||
WHERE user_id = $1 AND seen = FALSE AND deleted_at IS NULL`, userID)
|
||||
return tag.RowsAffected(), err
|
||||
}
|
||||
|
||||
func (s *Store) MarkClicked(ctx context.Context, id, userID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE notification.notifications
|
||||
SET clicked = TRUE, clicked_at = NOW(), seen = TRUE, seen_at = COALESCE(seen_at, NOW()), updated_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SoftDeleteNotification(ctx context.Context, id, userID uuid.UUID) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE notification.notifications SET deleted_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("notification not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CountUnread(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
var count int64
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM notification.notifications WHERE user_id = $1 AND seen = FALSE AND deleted_at IS NULL`, userID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ── Preferences ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListPreferences(ctx context.Context, userID uuid.UUID) ([]*models.NotificationPreference, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, notification_type::text, channel::text, enabled, updated_at
|
||||
FROM notification.notification_preferences WHERE user_id = $1 ORDER BY notification_type, channel`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.NotificationPreference
|
||||
for rows.Next() {
|
||||
p := &models.NotificationPreference{}
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.NotificationType, &p.Channel, &p.Enabled, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertPreference(ctx context.Context, userID uuid.UUID, req *models.UpdatePreferenceRequest) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO notification.notification_preferences (user_id, notification_type, channel, enabled)
|
||||
VALUES ($1, $2::notification_kind, $3::delivery_channel, $4)
|
||||
ON CONFLICT (user_id, notification_type, channel) DO UPDATE
|
||||
SET enabled = EXCLUDED.enabled, updated_at = NOW()`,
|
||||
userID, req.NotificationType, req.Channel, req.Enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Templates (admin) ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListTemplates(ctx context.Context, tenantID *uuid.UUID) ([]*models.NotificationTemplate, error) {
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
if tenantID != nil {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, code, channel::text, locale, subject, body_text, body_html,
|
||||
push_title, push_body, push_icon, variables_schema::text, is_active, created_at, updated_at
|
||||
FROM notification.notification_templates
|
||||
WHERE (tenant_id = $1 OR tenant_id IS NULL) AND is_active = TRUE
|
||||
ORDER BY code, channel, locale`, *tenantID)
|
||||
} else {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, code, channel::text, locale, subject, body_text, body_html,
|
||||
push_title, push_body, push_icon, variables_schema::text, is_active, created_at, updated_at
|
||||
FROM notification.notification_templates
|
||||
ORDER BY code, channel, locale`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanTemplates(rows)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertTemplate(ctx context.Context, tenantID *uuid.UUID, req *models.TemplateUpsertRequest) (*models.NotificationTemplate, error) {
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO notification.notification_templates
|
||||
(tenant_id, code, channel, locale, subject, body_text, body_html,
|
||||
push_title, push_body, push_icon, is_active)
|
||||
VALUES ($1,$2,$3::delivery_channel,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
ON CONFLICT (tenant_id, code, channel, locale) DO UPDATE SET
|
||||
subject = EXCLUDED.subject, body_text = EXCLUDED.body_text, body_html = EXCLUDED.body_html,
|
||||
push_title = EXCLUDED.push_title, push_body = EXCLUDED.push_body, push_icon = EXCLUDED.push_icon,
|
||||
is_active = EXCLUDED.is_active, updated_at = NOW()
|
||||
RETURNING id`,
|
||||
tenantID, req.Code, req.Channel, req.Locale, req.Subject, req.BodyText, req.BodyHTML,
|
||||
req.PushTitle, req.PushBody, req.PushIcon, isActive,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, code, channel::text, locale, subject, body_text, body_html,
|
||||
push_title, push_body, push_icon, variables_schema::text, is_active, created_at, updated_at
|
||||
FROM notification.notification_templates WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
tpls, err := scanTemplates(rows)
|
||||
if err != nil || len(tpls) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return tpls[0], nil
|
||||
}
|
||||
|
||||
// ── Deliveries ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) CreateDelivery(ctx context.Context, notifID uuid.UUID, userID, tenantID uuid.UUID, channel, recipient string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO notification.notification_deliveries
|
||||
(tenant_id, user_id, notification_id, channel, recipient, status)
|
||||
VALUES ($1,$2,$3,$4::delivery_channel,$5,'Pending')`,
|
||||
tenantID, userID, notifID, channel, recipient)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDeliveryStatus(ctx context.Context, id uuid.UUID, status, errMsg *string, providerMsgID *string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE notification.notification_deliveries SET
|
||||
status = $1::delivery_status_kind,
|
||||
error_message = $2,
|
||||
provider_message_id = $3,
|
||||
sent_at = CASE WHEN $1 = 'Sent' THEN NOW() ELSE sent_at END,
|
||||
delivered_at = CASE WHEN $1 = 'Delivered' THEN NOW() ELSE delivered_at END,
|
||||
failed_at = CASE WHEN $1 IN ('Failed','Bounced') THEN NOW() ELSE failed_at END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4`,
|
||||
status, errMsg, providerMsgID, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Scanners ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func scanNotifications(rows pgx.Rows) ([]*models.Notification, error) {
|
||||
var out []*models.Notification
|
||||
for rows.Next() {
|
||||
n := &models.Notification{}
|
||||
if err := rows.Scan(
|
||||
&n.ID, &n.TenantID, &n.UserID,
|
||||
&n.NotificationType, &n.Priority,
|
||||
&n.Title, &n.Message, &n.Label, &n.Signature, &n.Icon, &n.Image, &n.AnimationDemo, &n.Design,
|
||||
&n.ActionURL, &n.ActionText,
|
||||
&n.RenderJobID, &n.ExportID, &n.PaymentID, &n.GiftID, &n.EarnedGiftID,
|
||||
&n.IsEmergency, &n.Seen, &n.SeenAt, &n.Clicked, &n.ClickedAt, &n.GiftUsed,
|
||||
&n.ExpireDate, &n.CreatedAt, &n.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanTemplates(rows pgx.Rows) ([]*models.NotificationTemplate, error) {
|
||||
var out []*models.NotificationTemplate
|
||||
for rows.Next() {
|
||||
t := &models.NotificationTemplate{}
|
||||
var varsSchema *string
|
||||
if err := rows.Scan(&t.ID, &t.TenantID, &t.Code, &t.Channel, &t.Locale,
|
||||
&t.Subject, &t.BodyText, &t.BodyHTML, &t.PushTitle, &t.PushBody, &t.PushIcon,
|
||||
&varsSchema, &t.IsActive, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.VariablesSchema = varsSchema
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Cleanup helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) PurgeExpiredNotifications(ctx context.Context) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE notification.notifications
|
||||
SET deleted_at = NOW()
|
||||
WHERE expire_date < $1 AND deleted_at IS NULL`, time.Now())
|
||||
return tag.RowsAffected(), err
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/flatrender/notification-svc/internal/db"
|
||||
"github.com/flatrender/notification-svc/internal/middleware"
|
||||
"github.com/flatrender/notification-svc/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type NotificationHandler struct {
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
func NewNotificationHandler(store *db.Store) *NotificationHandler {
|
||||
return &NotificationHandler{store: store}
|
||||
}
|
||||
|
||||
// GET /v1/notifications
|
||||
func (h *NotificationHandler) List(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
onlyUnread := c.Query("unread") == "true"
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
notifs, total, err := h.store.ListNotifications(c.Request.Context(), userID, onlyUnread, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if notifs == nil {
|
||||
notifs = []*models.Notification{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.PagedResponse[*models.Notification]{
|
||||
Data: notifs,
|
||||
Meta: models.PaginationMeta{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
HasMore: int64(page*pageSize) < total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GET /v1/notifications/unread-count
|
||||
func (h *NotificationHandler) UnreadCount(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
count, err := h.store.CountUnread(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": count})
|
||||
}
|
||||
|
||||
// GET /v1/notifications/:id
|
||||
func (h *NotificationHandler) Get(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
||||
return
|
||||
}
|
||||
n, err := h.store.GetNotificationByID(c.Request.Context(), id, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, n)
|
||||
}
|
||||
|
||||
// POST /v1/notifications/:id/seen
|
||||
func (h *NotificationHandler) MarkSeen(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.store.MarkSeen(c.Request.Context(), id, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /v1/notifications/seen-all
|
||||
func (h *NotificationHandler) MarkAllSeen(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
count, err := h.store.MarkAllSeen(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"marked_seen": count})
|
||||
}
|
||||
|
||||
// POST /v1/notifications/:id/click
|
||||
func (h *NotificationHandler) MarkClicked(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.store.MarkClicked(c.Request.Context(), id, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DELETE /v1/notifications/:id
|
||||
func (h *NotificationHandler) Delete(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.store.SoftDeleteNotification(c.Request.Context(), id, userID); err != nil {
|
||||
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /v1/internal/notifications — called by other services to create notifications
|
||||
func (h *NotificationHandler) CreateInternal(c *gin.Context) {
|
||||
var req models.CreateNotificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
n, err := h.store.CreateNotification(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, n)
|
||||
}
|
||||
|
||||
// ── Preferences ───────────────────────────────────────────────────────────────
|
||||
|
||||
type PreferenceHandler struct {
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
func NewPreferenceHandler(store *db.Store) *PreferenceHandler {
|
||||
return &PreferenceHandler{store: store}
|
||||
}
|
||||
|
||||
// GET /v1/notifications/preferences
|
||||
func (h *PreferenceHandler) List(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
prefs, err := h.store.ListPreferences(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if prefs == nil {
|
||||
prefs = []*models.NotificationPreference{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": prefs})
|
||||
}
|
||||
|
||||
// PUT /v1/notifications/preferences
|
||||
func (h *PreferenceHandler) Upsert(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
var req models.UpdatePreferenceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.store.UpsertPreference(c.Request.Context(), userID, &req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Templates (admin) ─────────────────────────────────────────────────────────
|
||||
|
||||
type TemplateHandler struct {
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
func NewTemplateHandler(store *db.Store) *TemplateHandler {
|
||||
return &TemplateHandler{store: store}
|
||||
}
|
||||
|
||||
// GET /v1/notification-templates
|
||||
func (h *TemplateHandler) List(c *gin.Context) {
|
||||
tpls, err := h.store.ListTemplates(c.Request.Context(), nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if tpls == nil {
|
||||
tpls = []*models.NotificationTemplate{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": tpls})
|
||||
}
|
||||
|
||||
// PUT /v1/notification-templates
|
||||
func (h *TemplateHandler) Upsert(c *gin.Context) {
|
||||
var req models.TemplateUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
tpl, err := h.store.UpsertTemplate(c.Request.Context(), nil, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tpl)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/flatrender/notification-svc/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
CtxUserID = "user_id"
|
||||
CtxTenantID = "tenant_id"
|
||||
CtxIsAdmin = "is_admin"
|
||||
)
|
||||
|
||||
func JWTAuth(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
hdr := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(hdr, "Bearer ") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "missing bearer token"})
|
||||
return
|
||||
}
|
||||
token, err := jwt.Parse(hdr[7:], func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "invalid token"})
|
||||
return
|
||||
}
|
||||
claims, _ := token.Claims.(jwt.MapClaims)
|
||||
userID, _ := uuid.Parse(fmt.Sprintf("%v", claims["sub"]))
|
||||
tenantID, _ := uuid.Parse(fmt.Sprintf("%v", claims["tenant_id"]))
|
||||
isAdmin, _ := claims["is_admin"].(bool)
|
||||
c.Set(CtxUserID, userID)
|
||||
c.Set(CtxTenantID, tenantID)
|
||||
c.Set(CtxIsAdmin, isAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
v, _ := c.Get(CtxIsAdmin)
|
||||
b, _ := v.(bool)
|
||||
if !b {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, models.APIError{Code: "forbidden", Message: "admin required"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserID(c *gin.Context) uuid.UUID {
|
||||
v, _ := c.Get(CtxUserID)
|
||||
id, _ := v.(uuid.UUID)
|
||||
return id
|
||||
}
|
||||
|
||||
func GetTenantID(c *gin.Context) uuid.UUID {
|
||||
v, _ := c.Get(CtxTenantID)
|
||||
id, _ := v.(uuid.UUID)
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ── Enum consts ───────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// NotificationKind
|
||||
KindRenderCompleted = "RenderCompleted"
|
||||
KindRenderFailed = "RenderFailed"
|
||||
KindRenderProgress = "RenderProgress"
|
||||
KindPlanExpiring = "PlanExpiring"
|
||||
KindPlanExpired = "PlanExpired"
|
||||
KindPaymentSuccess = "PaymentSuccess"
|
||||
KindPaymentFailed = "PaymentFailed"
|
||||
KindStorageWarning = "StorageWarning"
|
||||
KindStorageFull = "StorageFull"
|
||||
KindExportExpiring = "ExportExpiring"
|
||||
KindExportDeleted = "ExportDeleted"
|
||||
KindGiftEarned = "GiftEarned"
|
||||
KindQuestCompleted = "QuestCompleted"
|
||||
KindLevelUp = "LevelUp"
|
||||
KindAccountSecurity = "AccountSecurity"
|
||||
KindSystemAnnouncement = "SystemAnnouncement"
|
||||
KindTenantInvite = "TenantInvite"
|
||||
KindMarketing = "Marketing"
|
||||
KindOther = "Other"
|
||||
|
||||
// Priority
|
||||
PriorityLow = "Low"
|
||||
PriorityNormal = "Normal"
|
||||
PriorityHigh = "High"
|
||||
PriorityUrgent = "Urgent"
|
||||
|
||||
// DeliveryChannel
|
||||
ChannelInApp = "InApp"
|
||||
ChannelPush = "Push"
|
||||
ChannelEmail = "Email"
|
||||
ChannelSMS = "SMS"
|
||||
ChannelTelegram = "Telegram"
|
||||
ChannelWebhook = "Webhook"
|
||||
|
||||
// DeliveryStatus
|
||||
StatusPending = "Pending"
|
||||
StatusSent = "Sent"
|
||||
StatusDelivered = "Delivered"
|
||||
StatusFailed = "Failed"
|
||||
StatusBounced = "Bounced"
|
||||
StatusSuppressed = "Suppressed"
|
||||
)
|
||||
|
||||
// ── Domain entities ──────────────────────────────────────────────────────────
|
||||
|
||||
type Notification struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
NotificationType string `json:"notification_type"`
|
||||
Priority string `json:"priority"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Signature *string `json:"signature,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
AnimationDemo *string `json:"animation_demo,omitempty"`
|
||||
Design *string `json:"design,omitempty"`
|
||||
ActionURL *string `json:"action_url,omitempty"`
|
||||
ActionText *string `json:"action_text,omitempty"`
|
||||
RenderJobID *uuid.UUID `json:"render_job_id,omitempty"`
|
||||
ExportID *uuid.UUID `json:"export_id,omitempty"`
|
||||
PaymentID *uuid.UUID `json:"payment_id,omitempty"`
|
||||
GiftID *uuid.UUID `json:"gift_id,omitempty"`
|
||||
EarnedGiftID *uuid.UUID `json:"earned_gift_id,omitempty"`
|
||||
IsEmergency bool `json:"is_emergency"`
|
||||
Seen bool `json:"seen"`
|
||||
SeenAt *time.Time `json:"seen_at,omitempty"`
|
||||
Clicked bool `json:"clicked"`
|
||||
ClickedAt *time.Time `json:"clicked_at,omitempty"`
|
||||
GiftUsed bool `json:"gift_used"`
|
||||
ExpireDate *time.Time `json:"expire_date,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type NotificationPreference struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
NotificationType string `json:"notification_type"`
|
||||
Channel string `json:"channel"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type NotificationTemplate struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID *uuid.UUID `json:"tenant_id,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Channel string `json:"channel"`
|
||||
Locale string `json:"locale"`
|
||||
Subject *string `json:"subject,omitempty"`
|
||||
BodyText *string `json:"body_text,omitempty"`
|
||||
BodyHTML *string `json:"body_html,omitempty"`
|
||||
PushTitle *string `json:"push_title,omitempty"`
|
||||
PushBody *string `json:"push_body,omitempty"`
|
||||
PushIcon *string `json:"push_icon,omitempty"`
|
||||
VariablesSchema *string `json:"variables_schema,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type NotificationDelivery struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
NotificationID *uuid.UUID `json:"notification_id,omitempty"`
|
||||
Channel string `json:"channel"`
|
||||
Recipient string `json:"recipient"`
|
||||
Subject *string `json:"subject,omitempty"`
|
||||
Provider *string `json:"provider,omitempty"`
|
||||
ProviderMessageID *string `json:"provider_message_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage *string `json:"error_message,omitempty"`
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
|
||||
FailedAt *time.Time `json:"failed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ── Request / Response types ─────────────────────────────────────────────────
|
||||
|
||||
type PagedResponse[T any] struct {
|
||||
Data []T `json:"data"`
|
||||
Meta PaginationMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type PaginationMeta struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type CreateNotificationRequest struct {
|
||||
UserID uuid.UUID `json:"user_id" binding:"required"`
|
||||
TenantID uuid.UUID `json:"tenant_id" binding:"required"`
|
||||
NotificationType string `json:"notification_type" binding:"required"`
|
||||
Priority *string `json:"priority"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
Label *string `json:"label"`
|
||||
Icon *string `json:"icon"`
|
||||
Image *string `json:"image"`
|
||||
ActionURL *string `json:"action_url"`
|
||||
ActionText *string `json:"action_text"`
|
||||
RenderJobID *uuid.UUID `json:"render_job_id"`
|
||||
ExportID *uuid.UUID `json:"export_id"`
|
||||
PaymentID *uuid.UUID `json:"payment_id"`
|
||||
GiftID *uuid.UUID `json:"gift_id"`
|
||||
EarnedGiftID *uuid.UUID `json:"earned_gift_id"`
|
||||
IsEmergency bool `json:"is_emergency"`
|
||||
ExpireDate *time.Time `json:"expire_date"`
|
||||
Channels []string `json:"channels"` // which delivery channels to trigger
|
||||
}
|
||||
|
||||
type UpdatePreferenceRequest struct {
|
||||
NotificationType string `json:"notification_type" binding:"required"`
|
||||
Channel string `json:"channel" binding:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type TemplateUpsertRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Channel string `json:"channel" binding:"required"`
|
||||
Locale string `json:"locale" binding:"required"`
|
||||
Subject *string `json:"subject"`
|
||||
BodyText *string `json:"body_text"`
|
||||
BodyHTML *string `json:"body_html"`
|
||||
PushTitle *string `json:"push_title"`
|
||||
PushBody *string `json:"push_body"`
|
||||
PushIcon *string `json:"push_icon"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
Reference in New Issue
Block a user