// Package messagesvc // Path: /internal/platform/services/messages // File: service.go package messagesvc import ( "context" "database/sql" "errors" "fmt" "time" "synlotto-website/internal/logging" domain "synlotto-website/internal/domain/messages" "github.com/go-sql-driver/mysql" ) // Service implements domain.Service. type Service struct { DB *sql.DB Dialect string // "postgres", "mysql", "sqlite" Now func() time.Time Timeout time.Duration } func New(db *sql.DB, opts ...func(*Service)) *Service { s := &Service{ DB: db, Dialect: "mysql", // default; works with LastInsertId Now: time.Now, Timeout: 5 * time.Second, } for _, opt := range opts { opt(s) } return s } // Ensure *Service satisfies the domain interface. var _ domain.MessageService = (*Service)(nil) func (s *Service) ListInbox(userID int64) ([]domain.Message, error) { ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) defer cancel() q := ` SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at FROM user_messages WHERE recipientId = ? AND is_archived = FALSE ORDER BY created_at DESC` q = s.bind(q) rows, err := s.DB.QueryContext(ctx, q, userID) if err != nil { return nil, err } defer rows.Close() var out []domain.Message for rows.Next() { var m domain.Message if err := rows.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt); err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } func (s *Service) ListArchived(userID int64) ([]domain.Message, error) { ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) defer cancel() q := ` SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at, archived_at FROM user_messages WHERE recipientId = ? AND is_archived = TRUE ORDER BY created_at DESC` q = s.bind(q) rows, err := s.DB.QueryContext(ctx, q, userID) if err != nil { return nil, err } defer rows.Close() var out []domain.Message for rows.Next() { var m domain.Message var archived sql.NullTime if err := rows.Scan( &m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt, &archived, ); err != nil { return nil, err } if archived.Valid { t := archived.Time m.ArchivedAt = &t } else { m.ArchivedAt = nil } out = append(out, m) } return out, rows.Err() } func (s *Service) GetByID(userID, id int64) (*domain.Message, error) { ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) defer cancel() q := ` SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at FROM user_messages WHERE recipientId = ? AND id = ?` q = s.bind(q) var m domain.Message err := s.DB.QueryRowContext(ctx, q, userID, id). Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &m, nil } func (s *Service) Create(senderID int64, in domain.CreateMessageInput) (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) defer cancel() // โœ… make sure this matches your current table/column names const q = ` INSERT INTO user_messages (senderId, recipientId, subject, body, is_read, is_archived, created_at) VALUES (?, ?, ?, ?, 0, 0, CURRENT_TIMESTAMP) ` // ๐Ÿ‘€ Log the SQL and arguments (truncate body in logs if you prefer) logging.Info("๐Ÿงช SQL Exec: %s | args: senderId=%d recipientId=%d subject=%q body_len=%d", compactSQL(q), senderID, in.RecipientID, in.Subject, len(in.Body)) res, err := s.DB.ExecContext(ctx, q, senderID, in.RecipientID, in.Subject, in.Body) if err != nil { // Surface MySQL code/message (very helpful for FK #1452 etc.) var me *mysql.MySQLError if errors.As(err, &me) { wrapped := fmt.Errorf("insert user_messages: mysql #%d %s | args(senderId=%d, recipientId=%d, subject=%q, body_len=%d)", me.Number, me.Message, senderID, in.RecipientID, in.Subject, len(in.Body)) logging.Info("โŒ %v", wrapped) return 0, wrapped } wrapped := fmt.Errorf("insert user_messages: %w | args(senderId=%d, recipientId=%d, subject=%q, body_len=%d)", err, senderID, in.RecipientID, in.Subject, len(in.Body)) logging.Info("โŒ %v", wrapped) return 0, wrapped } id, err := res.LastInsertId() if err != nil { wrapped := fmt.Errorf("lastInsertId user_messages: %w", err) logging.Info("โŒ %v", wrapped) return 0, wrapped } logging.Info("โœ… Inserted message id=%d", id) return id, nil } func compactSQL(s string) string { out := make([]rune, 0, len(s)) space := false for _, r := range s { if r == '\n' || r == '\t' || r == '\r' || r == ' ' { if !space { out = append(out, ' ') space = true } continue } space = false out = append(out, r) } return string(out) } func (s *Service) bind(q string) string { if s.Dialect != "postgres" { return q } n := 0 out := make([]byte, 0, len(q)+8) for i := 0; i < len(q); i++ { if q[i] == '?' { n++ out = append(out, '$') out = append(out, []byte(intToStr(n))...) continue } out = append(out, q[i]) } return string(out) } func (s *Service) Archive(userID, id int64) error { ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) defer cancel() q := ` UPDATE user_messages SET is_archived = 1, archived_at = CURRENT_TIMESTAMP WHERE id = ? AND recipientId = ? ` q = s.bind(q) res, err := s.DB.ExecContext(ctx, q, id, userID) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } func intToStr(n int) string { if n == 0 { return "0" } var b [12]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } return string(b[i:]) } func (s *Service) Unarchive(userID, id int64) error { ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) defer cancel() q := ` UPDATE user_messages SET is_archived = 0, archived_at = NULL WHERE id = ? AND recipientId = ? ` q = s.bind(q) res, err := s.DB.ExecContext(ctx, q, id, userID) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil }