package notification import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/alchemistkay/guestguard/internal/storage" ) type Channel string const ( ChannelSMS Channel = "sms" ChannelEmail Channel = "email" ) type Type string const ( TypeInvitation Type = "invitation" TypeVerification Type = "verification" TypeConfirmation Type = "confirmation" TypeReminder Type = "reminder" ) type Status string const ( StatusQueued Status = "queued" StatusSent Status = "sent" StatusDelivered Status = "delivered" StatusFailed Status = "failed" ) // Sender is the boundary between the worker and a real provider (Twilio, // SES, etc). Phase 3 ships a logging implementation; later phases swap it // out without touching consumer code. type Sender interface { Send(ctx context.Context, msg OutboundMessage) (providerID string, err error) } type OutboundMessage struct { GuestID uuid.UUID Channel Channel Type Type Subject string Body string Metadata map[string]any } // Repo persists notification records. type Repo struct { pool *pgxpool.Pool } func NewRepo(db *storage.DB) *Repo { return &Repo{pool: db.Pool} } type RecordParams struct { GuestID uuid.UUID Channel Channel Type Type Status Status ProviderID string // human-friendly id (e.g. "log:xyz") ProviderMessageID string // provider's message id (Twilio SID, SES MessageId) Error string } func (r *Repo) Record(ctx context.Context, p RecordParams) (uuid.UUID, error) { var providerID *string if p.ProviderID != "" { providerID = &p.ProviderID } var providerMsgID *string if p.ProviderMessageID != "" { providerMsgID = &p.ProviderMessageID } var errStr *string if p.Error != "" { errStr = &p.Error } var deliveredAt *time.Time if p.Status == StatusSent || p.Status == StatusDelivered { now := time.Now().UTC() deliveredAt = &now } const q = ` INSERT INTO notifications (guest_id, channel, type, status, provider_id, provider_message_id, attempts, last_attempt, delivered_at, error) VALUES ($1, $2, $3, $4, $5, $6, 1, now(), $7, $8) RETURNING id ` var id uuid.UUID err := r.pool.QueryRow(ctx, q, p.GuestID, string(p.Channel), string(p.Type), string(p.Status), providerID, providerMsgID, deliveredAt, errStr, ).Scan(&id) if err != nil { return uuid.Nil, fmt.Errorf("record notification: %w", err) } return id, nil } // MarkBounce records a bounce on the notification row identified by the // provider's message id. Called from webhook handlers. func (r *Repo) MarkBounce(ctx context.Context, providerMessageID, bounceType string) error { _, err := r.pool.Exec(ctx, ` UPDATE notifications SET status = 'bounced', bounce_type = $2, error = COALESCE(error, '') WHERE provider_message_id = $1 `, providerMessageID, bounceType) return err } // MarkComplaint records a complaint (spam report) for the same row. func (r *Repo) MarkComplaint(ctx context.Context, providerMessageID string) error { _, err := r.pool.Exec(ctx, ` UPDATE notifications SET complained = TRUE WHERE provider_message_id = $1 `, providerMessageID) return err } // MarkDelivered moves a row from 'sent' to 'delivered' when the provider's // delivery status webhook fires. func (r *Repo) MarkDelivered(ctx context.Context, providerMessageID string) error { _, err := r.pool.Exec(ctx, ` UPDATE notifications SET status = 'delivered', delivered_at = now() WHERE provider_message_id = $1 AND status NOT IN ('bounced','failed') `, providerMessageID) return err } // LogSender pretends to send and just logs. Useful for Phase 3 demos and // tests; concrete providers (Twilio/SES) plug in later. type LogSender struct{} func (LogSender) Send(_ context.Context, msg OutboundMessage) (string, error) { if msg.GuestID == uuid.Nil { return "", errors.New("missing guest id") } meta, _ := json.Marshal(msg.Metadata) providerID := "log:" + uuid.NewString() // We deliberately don't write to stdout here; the worker emits the slog // line so we control the structure. _ = meta return providerID, nil }