feat: send email notifications for missed messages
- New cron job in cluster - Handle presence and other concerns - Toggle in app to disable email notifications - Handles other edge cases such as cooldown period - Simple html email with simple message Closes #117
This commit is contained in:
@@ -50,10 +50,11 @@ func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc net
|
||||
// Response DTOs
|
||||
|
||||
type Human struct {
|
||||
Id string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
EmailPrefix string `json:"email_prefix"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Id string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
EmailPrefix string `json:"email_prefix"`
|
||||
EmailNotificationsEnabled bool `json:"email_notifications_enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Network struct {
|
||||
@@ -267,6 +268,35 @@ func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(dto)
|
||||
}
|
||||
|
||||
type UpdateSettingsRequest struct {
|
||||
EmailNotificationsEnabled *bool `json:"email_notifications_enabled"`
|
||||
}
|
||||
|
||||
// UpdateSettings updates the authenticated human's settings
|
||||
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateSettingsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.EmailNotificationsEnabled != nil {
|
||||
if err := h.humanSvc.UpdateEmailNotificationsEnabled(r.Context(), humanId, *req.EmailNotificationsEnabled); err != nil {
|
||||
slog.Error("failed to update email notifications setting", "error", err, "humanId", humanId)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Network Handlers
|
||||
// ============================================================================
|
||||
@@ -953,10 +983,11 @@ func waitlistEntryToDTO(e *waitlist.WaitlistEntry) WaitlistEntryResponse {
|
||||
|
||||
func humanToDTO(h *human.Human) Human {
|
||||
return Human{
|
||||
Id: h.ID,
|
||||
Email: h.Email,
|
||||
EmailPrefix: h.EmailPrefix,
|
||||
CreatedAt: h.CreatedAt,
|
||||
Id: h.ID,
|
||||
Email: h.Email,
|
||||
EmailPrefix: h.EmailPrefix,
|
||||
EmailNotificationsEnabled: h.EmailNotificationsEnabled,
|
||||
CreatedAt: h.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ package human
|
||||
import "time"
|
||||
|
||||
type Human struct {
|
||||
ID string
|
||||
Email string
|
||||
EmailPrefix string
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
Email string
|
||||
EmailPrefix string
|
||||
EmailNotificationsEnabled bool
|
||||
LastEmailNotificationSentAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -33,6 +34,9 @@ type repository interface {
|
||||
getByID(ctx context.Context, id string) (*Human, error)
|
||||
create(ctx context.Context, email string) (*Human, error)
|
||||
exists(ctx context.Context, email string) (bool, error)
|
||||
listAll(ctx context.Context) ([]*Human, error)
|
||||
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
@@ -46,9 +50,9 @@ func newRepository(pool *pgxpool.Pool) repository {
|
||||
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
||||
var h Human
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, created_at FROM humans WHERE email = $1`,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`,
|
||||
email,
|
||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
@@ -62,9 +66,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
|
||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
||||
var h Human
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, created_at FROM humans WHERE id = $1`,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
@@ -84,9 +88,9 @@ func (r *repositoryImpl) create(ctx context.Context, email string) (*Human, erro
|
||||
var h Human
|
||||
err = r.pool.QueryRow(ctx,
|
||||
`INSERT INTO humans (id, email) VALUES ($1, $2)
|
||||
RETURNING id, email, created_at`,
|
||||
RETURNING id, email, email_notifications_enabled, last_email_notification_sent_at, created_at`,
|
||||
id.String(), email,
|
||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -106,3 +110,52 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var humans []*Human
|
||||
for rows.Next() {
|
||||
var h Human
|
||||
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.EmailPrefix = emailPrefix(h.Email)
|
||||
humans = append(humans, &h)
|
||||
}
|
||||
return humans, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE humans SET email_notifications_enabled = $2 WHERE id = $1`,
|
||||
id, enabled,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE humans SET last_email_notification_sent_at = $2 WHERE id = $1`,
|
||||
id, t,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package human
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -16,6 +17,12 @@ type Service interface {
|
||||
GetByEmail(ctx context.Context, email string) (*Human, error)
|
||||
// GetByID returns ErrNotFound if no human found
|
||||
GetByID(ctx context.Context, id string) (*Human, error)
|
||||
// ListAll returns all humans
|
||||
ListAll(ctx context.Context) ([]*Human, error)
|
||||
// UpdateEmailNotificationsEnabled toggles email notification preference
|
||||
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||
// UpdateLastEmailNotificationSentAt records when the last notification email was sent
|
||||
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
@@ -62,3 +69,23 @@ func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Human, error) {
|
||||
}
|
||||
return h, err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) ListAll(ctx context.Context) ([]*Human, error) {
|
||||
return s.repo.listAll(ctx)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
err := s.repo.updateEmailNotificationsEnabled(ctx, id, enabled)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
|
||||
err := s.repo.updateLastEmailNotificationSentAt(ctx, id, t)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type repository interface {
|
||||
getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
|
||||
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||
isMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||
listAll(ctx context.Context) ([]*Network, error)
|
||||
|
||||
// Invitations
|
||||
createInvitation(ctx context.Context, networkID, email string) error
|
||||
@@ -202,6 +203,37 @@ func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string
|
||||
return isMember, err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, name, admin_human_id, created_at FROM networks`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var networks []*Network
|
||||
for rows.Next() {
|
||||
var n Network
|
||||
if err := rows.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
networks = append(networks, &n)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, n := range networks {
|
||||
n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
// Invitation methods
|
||||
|
||||
func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
|
||||
|
||||
@@ -26,6 +26,8 @@ type Service interface {
|
||||
RemoveMember(ctx context.Context, networkID, humanId string) error
|
||||
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||
// ListAll returns all networks with their members
|
||||
ListAll(ctx context.Context) ([]*Network, error)
|
||||
|
||||
// Invitations (email-based, for users who haven't registered yet)
|
||||
InviteByEmail(ctx context.Context, networkID string, emails []string) error
|
||||
@@ -116,6 +118,10 @@ func (s *serviceImpl) IsMember(ctx context.Context, networkID, humanId string) (
|
||||
return s.repo.isMember(ctx, networkID, humanId)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) ListAll(ctx context.Context) ([]*Network, error) {
|
||||
return s.repo.listAll(ctx)
|
||||
}
|
||||
|
||||
// Invitation methods
|
||||
|
||||
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
|
||||
|
||||
@@ -43,12 +43,13 @@ type FirestoreMediaParticleProperties struct {
|
||||
}
|
||||
|
||||
type FirestoreStreamParticle struct {
|
||||
CreatedByHumanId string `firestore:"created_by_human_id"`
|
||||
Type string `firestore:"type"`
|
||||
// Properties FirestoreStreamParticleProperties `firestore:"properties"`
|
||||
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
|
||||
LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"`
|
||||
VisibleTo []string `firestore:"visible_to"`
|
||||
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
|
||||
HuddleActiveParticipants []string `firestore:"huddle_active_participants,omitempty"`
|
||||
CreatedByHumanId string `firestore:"created_by_human_id"`
|
||||
Type string `firestore:"type"`
|
||||
Status string `firestore:"status"`
|
||||
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
|
||||
LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"`
|
||||
VisibleTo []string `firestore:"visible_to"`
|
||||
PlaybackMarkers map[string]time.Time `firestore:"playback_markers,omitempty"`
|
||||
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
|
||||
HuddleActiveParticipants []string `firestore:"huddle_active_participants,omitempty"`
|
||||
}
|
||||
|
||||
@@ -143,6 +143,41 @@ func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (ma
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAllConnectedHumanIDs scans all channel connection hashes in Redis and returns
|
||||
// the deduplicated set of all humanIDs that have at least one active connection.
|
||||
func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) {
|
||||
allHumanIDs := make(map[string]bool)
|
||||
var cursor uint64
|
||||
|
||||
for {
|
||||
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan channel keys: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
members, err := rb.client.HVals(ctx, key).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
continue
|
||||
}
|
||||
for _, humanID := range members {
|
||||
allHumanIDs[humanID] = true
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(allHumanIDs))
|
||||
for id := range allHumanIDs {
|
||||
result = append(result, id)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Pub/Sub listener (runs in its own goroutine) ---
|
||||
|
||||
// Listen subscribes to Redis Pub/Sub and forwards events to the local hub.
|
||||
|
||||
@@ -72,7 +72,22 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// BulkGetPresence implements the gRPC PusherService.
|
||||
// When channel_ids is empty, returns all connected humanIDs across all channels
|
||||
// under the key "_all" — useful for checking overall online status.
|
||||
func (s *Server) BulkGetPresence(ctx context.Context, req *pbpusher.BulkGetPresenceRequest) (*pbpusher.BulkGetPresenceResponse, error) {
|
||||
// Empty channel_ids → return all connected humans
|
||||
if len(req.ChannelIds) == 0 {
|
||||
humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pbpusher.BulkGetPresenceResponse{
|
||||
Presences: map[string]*pbpusher.ChannelPresence{
|
||||
"_all": {HumanIds: humanIDs},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
presence, err := s.bridge.GetPresence(ctx, req.ChannelIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user