Skip to content

Study engine/send message now#137

Merged
phev8 merged 16 commits into
mainfrom
study-engine/send-message-now
Aug 28, 2025
Merged

Study engine/send message now#137
phev8 merged 16 commits into
mainfrom
study-engine/send-message-now

Conversation

@phev8

@phev8 phev8 commented Aug 25, 2025

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Immediate study-triggered emails from rules with optional language override and 24‑hour expiry.
    • Pluggable message-sender for template-based study emails (participant API wires it up; other services pass nil for now).
  • Improvements

    • Typed contact-info (email/phone) and new method to fetch a user’s primary email.
    • Early-exit refinements in study condition evaluation for faster rule checks.
  • Reliability

    • Structured logging when email templates cannot be retrieved.
  • Bug Fixes

    • Use of computed participant ID when leaving a study.

@coderabbitai

coderabbitai Bot commented Aug 25, 2025

Copy link
Copy Markdown

Walkthrough

Adds a pluggable StudyMessageSender to the study engine, extends Init to accept and register it, implements a concrete sender and SendInstantStudyEmail, adds SEND_MESSAGE_NOW action and payload assembly, adjusts expression loop control, swaps email-template lookup arg order with structured logging, and introduces typed contact-info and GetEmail.

Changes

Cohort / File(s) Summary of Changes
Study init & registration
pkg/study/study-service.go
Init gains a 4th studyMessageSender parameter and registers it with the engine; OnLeaveStudy now fetches participant state using computed participantID.
Study engine types & DB API
pkg/study/studyengine/types.go
Adds StudyMessageSender interface, SendOptions, messageSender field on StudyEngine, RegisterStudyMessageSender method, and new StudyDBService methods for researcher messages and study code list operations.
Concrete message sender implementation
pkg/study/studyengine/sender/sender.go
New file: StudyMessageSender struct, MessageSenderConfig, NewStudyMessageSender, and SendInstantStudyEmail implementing profile resolution, optional login token creation, payload enrichment, language/expiry handling, and template send orchestration.
Engine actions: immediate send
pkg/study/studyengine/actions.go
Adds SEND_MESSAGE_NOW action, getExtraPayload, and sendMessageNow which validates sender, parses args (messageType, optional language), builds extra payload and calls SendInstantStudyEmail (default 24h expiry).
Expressions control flow
pkg/study/studyengine/expressions.go
checkConditionForOldResponses now uses early returns inside the loop (e.g., return false/true) to exit sooner; overall logic preserved.
Email utils & DB lookup arg order
pkg/messaging/email-sending/utils.go
Adds log/slog import; swapped arg order for GetStudyEmailTemplateByMessageType to (instanceID, studyKey, messageType); adds structured slog.Error logging on template fetch failures.
Service wiring: management & participant APIs
services/management-api/init.go, services/participant-api/init.go
study.Init calls updated to pass a 4th sender arg; participant-api constructs and passes a concrete StudyMessageSender (uses LoginTokenTTL and GlobalEmailTemplateConstants) and reorders init sequence; management-api passes nil.
Job call sites (nil sender)
jobs/messaging/init.go, jobs/study-timer/init.go, jobs/user-management/init.go
Updated study.Init calls to pass nil as the new 4th argument; no other logic changes.
User contact-info types & helpers
pkg/user-management/types/user.go, pkg/user-management/types/contact-info.go
Introduces ContactInfoType and constants CONTACT_INFO_TYPE_EMAIL, CONTACT_INFO_TYPE_PHONE; changes ContactInfo.Type to ContactInfoType; makes Email/Phone fields omitempty; replaces string literals with typed constants across methods; adds (*User).GetEmail() and updates signatures (e.g., ConfirmContactInfo, SetContactInfoVerificationSent, FindContactInfoByTypeAndAddr).
API handlers: contact-info usage
services/participant-api/apihandlers/authentication.go, services/participant-api/apihandlers/user-management.go
Switches contact-info lookups and calls to use userTypes.CONTACT_INFO_TYPE_EMAIL and casts where needed; minor refactor of argument formatting.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Service as Service (management/participant)
  participant Sender as StudyMessageSender
  participant Study as study.Init
  participant Engine as StudyEngine

  Service->>Sender: NewStudyMessageSender(deps, config)    %% participant-api only
  Service->>Study: Init(db, globalSecret, externalServices, Sender/nil)
  Study->>Engine: RegisterStudyMessageSender(Sender)
  note right of Engine: Engine can invoke SEND_MESSAGE_NOW action
Loading
sequenceDiagram
  autonumber
  participant Engine as StudyEngine
  participant Sender as StudyMessageSender
  participant DBs as DBs (study/participant/messaging/globalinfos)
  participant Email as Email Templating

  rect rgba(230,250,230,0.6)
  note over Engine,Sender: Action: SEND_MESSAGE_NOW
  Engine->>Engine: Build extra payload (flags, linkingCodes, eventData)
  Engine->>Sender: SendInstantStudyEmail(instanceID, studyKey, confidentialPID, messageType, payload, opts)
  end

  Sender->>DBs: Resolve profile ID, load user/profile
  alt create login token
    Sender->>DBs: Persist temporary login token
  end
  Sender->>Email: Render & send email template with payload
  Email-->>Sender: send result
  Sender-->>Engine: success or error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I twitch my ears at signals new,
A sender hops into the queue,
Tokens hide in carrot beds,
Templates dress the inbox heads,
I hop, I send — a carrot mail for you 🥕🐇

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch study-engine/send-message-now

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
pkg/study/studyengine/expressions.go (1)

400-406: Guard the type assertion to prevent a panic on non-boolean results.

expResult.(bool) will panic if the inner expression does not resolve to a boolean (misconfigured rules, user error, or future changes). Prefer a safe assertion and return a typed error.

Apply this diff:

-        val := expResult.(bool)
+        val, ok := expResult.(bool)
+        if !ok {
+            return false, fmt.Errorf("checkConditionForOldResponses: expected bool result, got %T", expResult)
+        }
pkg/user-management/types/user.go (4)

84-95: Guard against type/address mismatches when confirming contact info

The condition checks the requested type t but doesn’t ensure the matched ContactInfo actually has the same Type. If data is inconsistent, we might confirm the wrong record. Add a ci.Type == t guard.

Apply this diff:

 func (u *User) ConfirmContactInfo(t string, addr string) error {
 	for i, ci := range u.ContactInfos {
-		if t == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
-			u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
-			return nil
-		} else if t == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
-			u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
-			return nil
-		}
+		if t == CONTACT_INFO_TYPE_EMAIL && ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+			u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
+			return nil
+		} else if t == CONTACT_INFO_TYPE_PHONE && ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
+			u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
+			return nil
+		}
 	}
 	return errors.New("contact not found")
 }

97-107: Also guard ci.Type in SetContactInfoVerificationSent

Same mismatch risk as above; set the sent-at timestamp only when type and address both match the same record.

Apply this diff:

 func (u *User) SetContactInfoVerificationSent(t string, addr string) {
 	for i, ci := range u.ContactInfos {
-		if t == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+		if t == CONTACT_INFO_TYPE_EMAIL && ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
 			u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
 			return
-		} else if t == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
+		} else if t == CONTACT_INFO_TYPE_PHONE && ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
 			u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
 			return
 		}
 	}
 }

109-118: FindContactInfoByTypeAndAddr should also verify ci.Type matches t

Currently it matches on requested type and address, but does not ensure the ContactInfo’s Type matches. Add the ci.Type check to avoid false positives.

Apply this diff:

 func (u User) FindContactInfoByTypeAndAddr(t string, addr string) (ContactInfo, bool) {
 	for _, ci := range u.ContactInfos {
-		if t == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+		if t == CONTACT_INFO_TYPE_EMAIL && ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
 			return ci, true
-		} else if t == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
+		} else if t == CONTACT_INFO_TYPE_PHONE && ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
 			return ci, true
 		}
 	}
 	return ContactInfo{}, false
 }

129-143: Bug: RemoveContactInfo doesn’t clean up contact preferences on successful removal

The docstring says it should remove references from contact preferences too, but on success it returns without that cleanup. This can leave dangling IDs in ContactPreferences.SendNewsletterTo.

Apply this diff:

 		if ci.ID.Hex() == id {
 			if u.Account.Type == ACCOUNT_TYPE_EMAIL && ci.Email == u.Account.AccountID {
 				return errors.New("cannot remove main address")
 			}
 
-			u.ContactInfos = append(u.ContactInfos[:i], u.ContactInfos[i+1:]...)
-			return nil
+			u.ContactInfos = append(u.ContactInfos[:i], u.ContactInfos[i+1:]...)
+			u.RemoveContactInfoFromContactPreferences(id)
+			return nil
 		}

Optionally, consider making RemoveContactInfoFromContactPreferences return whether it changed anything and log if a dangling reference was found when the contact itself didn’t exist.

pkg/study/study-service.go (1)

1106-1114: Bug: GetParticipantByID called with profileID instead of participantID.

This will fail for most studies because participantID != profileID. Use the computed participantID from above.

-    pState, err := studyDBService.GetParticipantByID(instanceID, studyKey, profileID)
+    pState, err := studyDBService.GetParticipantByID(instanceID, studyKey, participantID)
🧹 Nitpick comments (16)
pkg/study/studyengine/expressions.go (3)

420-425: Count branch: early-exit is correct; minor idiomatic tweak.

  • Logic to return as soon as counter >= checkForCount is set is correct and improves performance.
  • Nit: counter++ is a bit more idiomatic than counter += 1 in Go.
-                counter += 1
+                counter++

309-328: Validate non-positive count threshold upfront to avoid surprising results.

If checkFor is "count" and checkForCount <= 0, the expression is trivially satisfied. Currently it can only return true after encountering at least one true response, which may be surprising for 0. Consider short-circuiting.

         switch arg1Val := arg1.(type) {
         case string:
             checkFor = arg1Val
         case float64:
             checkFor = "count"
             checkForCount = int(arg1Val)
         default:
             return val, fmt.Errorf("type unknown %T", arg1Val)
         }
+        if checkFor == "count" && checkForCount <= 0 {
+            return true, nil
+        }

391-429: Drop the loop label by using early returns to simplify control flow.

Labels are correct but can be avoided here, reducing cognitive load and indentation. This keeps behavior identical while making intent clearer.

-responsesLoop:
 for _, resp := range responses {
     oldEvalContext := EvalContext{
         ParticipantState: ctx.ParticipantState,
         Event: StudyEvent{
             Response: resp,
         },
     }

     expResult, err := ExpressionEval(*condition, oldEvalContext)
     if err != nil {
         return false, err
     }
-    val := expResult.(bool)
+    val, ok := expResult.(bool)
+    if !ok {
+        return false, fmt.Errorf("checkConditionForOldResponses: expected bool result, got %T", expResult)
+    }

     switch checkFor {
     case "all":
         if val {
             result = true
         } else {
-            result = false
-            break responsesLoop
+            return false, nil
         }
     case "any":
         if val {
-            result = true
-            break responsesLoop
+            return true, nil
         }
     case "count":
         if val {
-            counter += 1
+            counter++
             if counter >= checkForCount {
-                result = true
-                break responsesLoop
+                return true, nil
             }
         }
     }
 }
 
 return result, nil
pkg/user-management/types/user.go (1)

12-13: Introduce a dedicated ContactInfoType alias and centralize “email”/“phone” constants

Defining a typed alias and moving these values into one file makes the intent clearer and prevents future drift. Here’s what to do:

• Create pkg/user-management/types/contact_info_types.go containing:

package types

// ContactInfoType distinguishes how a user can be contacted.
type ContactInfoType string

const (
    ContactInfoTypeEmail ContactInfoType = "email"
    ContactInfoTypePhone ContactInfoType = "phone"
)

• In pkg/user-management/types/user.go, replace

const CONTACT_INFO_TYPE_EMAIL = "email"
const CONTACT_INFO_TYPE_PHONE = "phone"

with

// deprecated: use types.ContactInfoTypeEmail
const CONTACT_INFO_TYPE_EMAIL = string(ContactInfoTypeEmail)
// deprecated: use types.ContactInfoTypePhone
const CONTACT_INFO_TYPE_PHONE = string(ContactInfoTypePhone)

• Update the signature of methods like FindContactInfoByTypeAndAddr to accept ContactInfoType instead of string, and change all calls from

user.FindContactInfoByTypeAndAddr("email", addr)

to

user.FindContactInfoByTypeAndAddr(types.ContactInfoTypeEmail, addr)

• Migrate the raw literals you found to use the new constants; key locations include (but aren’t limited to):
– services/participant-api/apihandlers/utils.go (lines 40, 71)
– services/participant-api/apihandlers/password-reset.go
– services/participant-api/apihandlers/user-management.go (lines 299, 330, 340)
– services/participant-api/apihandlers/authentication.go (e.g. lines 713–715, 867–872)
– jobs/user-management/main.go (lines 133, 223)
– pkg/user-management/types/otp.go (EmailOTP constant)
– pkg/study/exporter/survey-definition/compToResponseDef.go (cases “email”/“phone”)
– jobs/messaging/scheduled-messages.go (lines 108–110, 159–163, 228–230, 273–275)
– services/management-api/apihandlers/user-management.go (lines 253, 257)
– services/management-api/apihandlers/management-auth.go (logging on sign-up/sign-in)

You can batch-migrate with your IDE or rg + sed, and then remove the deprecated untyped constants once the codebase no longer references them.

pkg/messaging/email-sending/utils.go (1)

34-38: Prefer structured error fields (slog.Any) and include language for better triage.

Logging err.Error() as a string loses stack details when aggregated. Also, lang is part of the fetch context and helpful when diagnosing missing translations.

Apply:

-    slog.Error("failed to fetch email template",
-        slog.String("instanceID", instanceID),
-        slog.String("messageType", messageType),
-        slog.String("studyKey", studyKey),
-        slog.String("error", err.Error()))
+    slog.Error("failed to fetch email template",
+        slog.String("instanceID", instanceID),
+        slog.String("studyKey", studyKey),
+        slog.String("messageType", messageType),
+        slog.String("lang", lang),
+        slog.Any("err", err))
pkg/study/studyengine/types.go (4)

24-25: Field addition is fine; document that nil is a valid/expected state.

Given jobs pass nil intentionally, a short comment on messageSender being optional will help future maintainers.

 type StudyEngine struct {
     studyDBService   StudyDBService
     externalServices []ExternalService
-    messageSender    StudyMessageSender
+    // messageSender may be nil (e.g., in jobs that do not send messages).
+    messageSender    StudyMessageSender
 }

38-46: Avoid using the global inside a method; use the receiver and add a nil receiver guard.

Current code unnecessarily couples to the global singleton. Using the receiver improves testability and clarity.

-func (se *StudyEngine) RegisterStudyMessageSender(sender StudyMessageSender) {
-    if CurrentStudyEngine != nil {
-        CurrentStudyEngine.messageSender = sender
-    } else {
-        slog.Error("StudyEngine not initialized, cannot register message sender")
-    }
-}
+func (se *StudyEngine) RegisterStudyMessageSender(sender StudyMessageSender) {
+    if se == nil {
+        slog.Error("StudyEngine not initialized, cannot register message sender")
+        return
+    }
+    se.messageSender = sender
+}

93-97: Clarify time unit for ExpiresAt to avoid ambiguity.

Document that the value is Unix seconds to prevent misinterpretation (ms vs s).

 type SendOptions struct {
-    ExpiresAt        int64 // if message could not sent until this time, it will be discarded
+    ExpiresAt        int64 // Unix seconds. If sending hasn't succeeded by this timestamp, discard the message.
     LanguageOverride string
 }

101-110: Consider accepting context.Context for cancellation/deadlines.

If sending can involve network IO or IO-bound operations, a context allows cooperative cancellation and better deadline propagation. If changing the interface now is too invasive, consider a follow-up.

pkg/study/study-service.go (1)

28-43: Init wiring is correct; consider logging when no sender is provided.

Registering a nil sender is acceptable. Emitting a debug/info log when sender is nil can help catch misconfigurations during deployment.

 studyengine.InitStudyEngine(studyDB, externalServices)
-studyengine.CurrentStudyEngine.RegisterStudyMessageSender(studyMessageSender)
+studyengine.CurrentStudyEngine.RegisterStudyMessageSender(studyMessageSender)
+if studyMessageSender == nil {
+    slog.Debug("No StudyMessageSender configured; SEND_MESSAGE_NOW actions will be no-ops")
+}
pkg/study/studyengine/actions.go (3)

63-65: New SEND_MESSAGE_NOW action: add to docs/playbooks and expression builders.

Looks good. Please ensure authoring tools, docs, and any validation that enumerates action names include "SEND_MESSAGE_NOW" to avoid runtime “action name not known” errors when composing expressions.


653-674: Avoid duplicate ‘studyKey’ in payload (sender adds it again).

getExtraPayload injects "studyKey", and the sender builds a payload with "studyKey" too. This duplication is harmless but redundant and can mask accidental divergence. Suggest removing it here and letting the sender be the single source of truth.

Apply this diff:

-func getExtraPayload(pState studyTypes.Participant, event StudyEvent) map[string]string {
-	payload := map[string]string{
-		"studyKey": event.StudyKey,
-	}
+func getExtraPayload(pState studyTypes.Participant, event StudyEvent) map[string]string {
+	payload := map[string]string{}

676-734: Harden argument parsing and preconditions; improve error clarity.

  • Don’t reuse ok1 for language parsing; use a separate variable for readability.
  • Fail fast if ParticipantIDForConfidentialResponses is empty; currently the sender fails later with a generic “profileID lookup failed”.

Apply this diff:

-	languageOverride := ""
+	languageOverride := ""
 	if len(action.Data) > 1 {
 		arg2, err := EvalContext.ExpressionArgResolver(action.Data[1])
 		if err != nil {
 			return newState, err
 		}
-		languageOverride, ok1 = arg2.(string)
-		if !ok1 {
+		langOK := false
+		languageOverride, langOK = arg2.(string)
+		if !langOK {
 			slog.Debug("could not parse language override")
 		}
 	}
 
+	// Precondition: we need a confidential participant ID to resolve user/profile
+	if event.ParticipantIDForConfidentialResponses == "" {
+		slog.Debug("sendMessageNow: missing participantID for confidential responses")
+		return newState, errors.New("sendMessageNow requires participantIDForConfidentialResponses")
+	}
+
 	extraPayload := getExtraPayload(newState.PState, event)

I can add unit tests for:

  • missing sender
  • missing args
  • language override parsing
  • payload composition (flags/linkingCodes/eventData)
  • ExpiresAt ≈ Now()+24h (using the overridable Now()).
services/management-api/init.go (1)

166-171: Option: pass nil until Management API gains messaging configuration.

Safer default to avoid partial configuration.

Apply this diff:

-	study.Init(
-		studyDBService,
-		conf.StudyConfigs.GlobalSecret,
-		conf.StudyConfigs.ExternalServices,
-		sender,
-	)
+	study.Init(
+		studyDBService,
+		conf.StudyConfigs.GlobalSecret,
+		conf.StudyConfigs.ExternalServices,
+		nil, // no messaging sender in management-api until messaging is initialized here
+	)
pkg/study/studyengine/sender/sender.go (2)

118-129: Default ExpiresAt if not provided.

Make the sender robust to callers forgetting to set ExpiresAt by applying a sensible default (e.g., 24h).

Apply this diff:

-	err = emailsending.SendInstantEmailByTemplate(
+	expiresAt := opts.ExpiresAt
+	if expiresAt == 0 {
+		expiresAt = time.Now().Add(24 * time.Hour).Unix()
+	}
+	err = emailsending.SendInstantEmailByTemplate(
 		instanceID,
 		to,
 		messageType,
 		studyKey,
 		lang,
 		payload,
 		false, // useLowPrio
-		opts.ExpiresAt,
+		expiresAt,
 	)

136-151: Guard zero TTL for login tokens.

If LoginTokenTTL was not configured, default to a safe value to avoid creating already-expired tokens.

Apply this diff:

-	tempTokenInfos := umTypes.TempToken{
+	ttl := s.loginTokenTTL
+	if ttl == 0 {
+		ttl = 24 * time.Hour
+	}
+	tempTokenInfos := umTypes.TempToken{
 		UserID:     user.ID.Hex(),
 		InstanceID: instanceID,
 		Purpose:    umTypes.TOKEN_PURPOSE_SURVEY_LOGIN,
 		Info:       map[string]string{"studyKey": studyKey},
-		Expiration: umUtils.GetExpirationTime(s.loginTokenTTL),
+		Expiration: umUtils.GetExpirationTime(ttl),
 	}

I can add focused tests with fakes for studyDB/participantUserDB/globalInfosDB to exercise:

  • missing/invalid confidentialPID
  • missing profiles
  • language override and payload merge
  • zero TTL and zero ExpiresAt defaults.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0ce47a5 and c7e3343.

📒 Files selected for processing (12)
  • jobs/messaging/init.go (1 hunks)
  • jobs/study-timer/init.go (1 hunks)
  • jobs/user-management/init.go (1 hunks)
  • pkg/messaging/email-sending/utils.go (2 hunks)
  • pkg/study/study-service.go (2 hunks)
  • pkg/study/studyengine/actions.go (2 hunks)
  • pkg/study/studyengine/expressions.go (2 hunks)
  • pkg/study/studyengine/sender/sender.go (1 hunks)
  • pkg/study/studyengine/types.go (4 hunks)
  • pkg/user-management/types/user.go (9 hunks)
  • services/management-api/init.go (2 hunks)
  • services/participant-api/init.go (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
pkg/study/study-service.go (2)
pkg/study/studyengine/sender/sender.go (1)
  • StudyMessageSender (20-27)
pkg/study/studyengine/types.go (2)
  • StudyMessageSender (101-110)
  • CurrentStudyEngine (28-28)
pkg/study/studyengine/sender/sender.go (6)
pkg/study/studyengine/types.go (1)
  • SendOptions (94-97)
pkg/user-management/types/account.go (1)
  • Account (3-15)
pkg/messaging/email-sending/send.go (1)
  • SendInstantEmailByTemplate (61-110)
pkg/user-management/types/user.go (1)
  • User (15-23)
pkg/user-management/types/temptoken.go (2)
  • TempToken (19-27)
  • TOKEN_PURPOSE_SURVEY_LOGIN (13-13)
pkg/user-management/utils/temp-tokens.go (1)
  • GetExpirationTime (33-35)
pkg/study/studyengine/actions.go (4)
pkg/study/types/participant.go (1)
  • Participant (14-26)
pkg/study/studyengine/types.go (5)
  • StudyEvent (76-85)
  • ActionData (57-60)
  • CurrentStudyEngine (28-28)
  • EvalContext (88-91)
  • SendOptions (94-97)
pkg/study/types/expression.go (1)
  • Expression (3-10)
pkg/study/studyengine/helpers.go (1)
  • Now (13-13)
services/management-api/init.go (2)
pkg/study/studyengine/sender/sender.go (2)
  • NewStudyMessageSender (34-49)
  • MessageSenderConfig (29-32)
pkg/study/study-service.go (1)
  • Init (28-43)
pkg/user-management/types/user.go (2)
pkg/user-management/types/contact-info.go (1)
  • ContactInfo (5-12)
pkg/user-management/types/account.go (1)
  • Account (3-15)
services/participant-api/init.go (3)
pkg/study/studyengine/sender/sender.go (2)
  • NewStudyMessageSender (34-49)
  • MessageSenderConfig (29-32)
pkg/messaging/types/messaging-config.go (1)
  • MessagingConfigs (12-22)
pkg/study/study-service.go (1)
  • Init (28-43)
🔇 Additional comments (17)
pkg/study/studyengine/expressions.go (2)

391-398: Good fix: labeled break exits the for-range loop (not just the switch).

Using responsesLoop: and break responsesLoop correctly implements early-exit from the outer loop, which the previous plain break would not do inside a switch. This is both a correctness and performance improvement when the condition is met early.


406-418: Ensure and Document Empty-Set Semantics for checkConditionForOldResponses

  • Confirm that returning false for the "all" case when there are zero responses is your intended behavior (many definitions treat “all of an empty set” as true).
  • Add explicit unit tests covering an empty-response scenario:
    • t.Run("checkType all – empty responses", …) expecting true or false per your spec
    • t.Run("checkType any – empty responses", …) expecting false
  • Update the function’s GoDoc (or the README) to clearly state the empty-set behavior for "all" and "any"
pkg/user-management/types/user.go (4)

25-37: LGTM: AddNewEmail now uses CONTACT_INFO_TYPE_EMAIL

Replacing the inline literal with the constant improves consistency. No functional issues spotted.


48-63: LGTM: SetPhoneNumber filtering and creation use CONTACT_INFO_TYPE_PHONE

The migration away from magic strings is correct; the behavior of replacing existing phone entries is preserved.


65-73: LGTM: ConfirmPhoneNumber uses CONTACT_INFO_TYPE_PHONE

The change preserves behavior while aligning with constants.


75-82: LGTM: GetPhoneNumber uses CONTACT_INFO_TYPE_PHONE

Behavior maintained; naming and returns remain consistent with the rest of the API.

pkg/messaging/email-sending/utils.go (1)

31-32: Argument order swap verified across all call sites
I’ve checked every invocation of GetStudyEmailTemplateByMessageType in the repo—including

  • services/management-api/apihandlers/messaging-service.go
  • pkg/messaging/email-sending/utils.go
  • pkg/db/messaging/email-templates.go
  • jobs/messaging/participant-messages.go
  • jobs/messaging/researcher-notifications.go

All calls now correctly use (instanceID, studyKey, messageType) per the updated DB API. No further changes are needed.

jobs/user-management/init.go (1)

248-255: Nil sender safety confirmed for study service initialization.

All calls to SendInstantStudyEmail in pkg/study/studyengine are properly guarded by a nil‐check on CurrentStudyEngine.messageSender (see actions.go around line 679), so passing nil in initStudyService is safe. No unguarded messaging paths are reachable from this job, and no further changes are required.

pkg/study/study-service.go (1)

28-43: All study.Init call sites are using the 4-argument signature

I’ve reviewed every invocation in:

  • services/management-api/init.go
  • services/participant-api/init.go
  • jobs/user-management/init.go
  • jobs/study-timer/init.go
  • jobs/messaging/init.go

and confirmed they all pass four parameters (including the global secret and a message sender or nil). There are no remaining 3-arg calls.

jobs/messaging/init.go (1)

208-215: Nil sender usage is safe—no further action needed

I ran a search for any direct calls to SEND_MESSAGE_NOW, SendInstantStudyEmail, or use of .messageSender within the jobs/messaging code and found no matches (only the handlers in pkg/study/studyengine, which guard against a nil sender). Moreover, in studyengine/actions.go, the "SEND_MESSAGE_NOW" case checks for CurrentStudyEngine.messageSender == nil and returns early before any call to SendInstantStudyEmail, ensuring a nil sender no-ops gracefully.

No code changes are required here. Great work!

jobs/study-timer/init.go (2)

135-141: Init signature update wired correctly (passing nil sender).

The updated call to study.Init matches the new 4-arg signature, and passing nil for the StudyMessageSender is appropriate here since this job does not send messages.


135-141: Confirm this job never triggers SEND_MESSAGE_NOW actions.

If this process evaluates a study rule with SEND_MESSAGE_NOW, the action will return an error ("message sender for study engine not registered"). If that’s acceptable, keep nil; otherwise consider wiring a real sender or guarding against this action in job configs.

services/management-api/init.go (1)

154-165: Management API builds a sender but never initializes the email-sending module.

This process doesn’t call emailsending.InitMessageSendingVariables, so SendInstantStudyEmail will fail here with “connection to smtp bridge not initialized.” If Management API isn’t meant to send immediate study emails, prefer passing nil instead of constructing a sender. If it should, wire the messaging config and call emailsending.InitMessageSendingVariables.

services/participant-api/init.go (3)

17-18: Import alias for sender is appropriate.

The import and aliasing are clear and consistent with usage.


170-174: Good: message-sending config is initialized before creating the sender.

Ordering guarantees the email-sending module is ready before the sender is registered with the study engine.


269-286: Sender wiring and TTL/constants look correct.

Constructing the StudyMessageSender with a 24h login token TTL and passing messaging constants from config is sound.

pkg/study/studyengine/sender/sender.go (1)

3-7: No changes needed: Go 1.23.0 in go.mod already supports maps.Copy

The go.mod file declares go 1.23.0, which is ≥ 1.21 and thus includes the maps package and its Copy function—no update required.

Comment thread pkg/study/studyengine/sender/sender.go
Comment thread pkg/user-management/types/user.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
pkg/user-management/types/user.go (4)

48-63: SetPhoneNumber drops existing phone ContactInfos but leaves stale IDs in ContactPreferences

Old phone ContactInfo IDs remain referenced (e.g., SendNewsletterTo), causing dangling references and potential “contact not found” at send time. Remove those references when you purge old phone entries.

Apply this diff:

 func (u *User) SetPhoneNumber(phone string) {
-	var newContactInfos []ContactInfo
+	var newContactInfos []ContactInfo
+	var removedPhoneIDs []string
 	for _, ci := range u.ContactInfos {
-		if ci.Type == CONTACT_INFO_TYPE_PHONE {
-			continue
-		}
+		if ci.Type == CONTACT_INFO_TYPE_PHONE {
+			removedPhoneIDs = append(removedPhoneIDs, ci.ID.Hex())
+			continue
+		}
 		newContactInfos = append(newContactInfos, ci)
 	}
 	contactInfo := ContactInfo{
 		ID:          primitive.NewObjectID(),
 		Type:        CONTACT_INFO_TYPE_PHONE,
 		ConfirmedAt: 0,
 		Phone:       phone,
 	}
-	u.ContactInfos = append(newContactInfos, contactInfo)
+	// Clean up dangling references in preferences
+	for _, id := range removedPhoneIDs {
+		u.RemoveContactInfoFromContactPreferences(id)
+	}
+	u.ContactInfos = append(newContactInfos, contactInfo)
 }

84-95: ConfirmContactInfo should also check ci.Type; current code can match by field alone

Guard against accidental matches by validating both the requested type and the stored ContactInfo.Type.

Apply this diff:

 func (u *User) ConfirmContactInfo(t string, addr string) error {
 	for i, ci := range u.ContactInfos {
-		if t == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
-			u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
-			return nil
-		} else if t == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
-			u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
-			return nil
-		}
+		switch t {
+		case CONTACT_INFO_TYPE_EMAIL:
+			if ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+				u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
+				return nil
+			}
+		case CONTACT_INFO_TYPE_PHONE:
+			if ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
+				u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
+				return nil
+			}
+		}
 	}
 	return errors.New("contact not found")
 }

109-118: FindContactInfoByTypeAndAddr ignores ci.Type; function name implies it should validate type

Use ci.Type in the match to prevent false positives and align behavior with the name.

Apply this diff:

 func (u User) FindContactInfoByTypeAndAddr(t string, addr string) (ContactInfo, bool) {
 	for _, ci := range u.ContactInfos {
-		if t == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
-			return ci, true
-		} else if t == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
-			return ci, true
-		}
+		switch t {
+		case CONTACT_INFO_TYPE_EMAIL:
+			if ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+				return ci, true
+			}
+		case CONTACT_INFO_TYPE_PHONE:
+			if ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
+				return ci, true
+			}
+		}
 	}
 	return ContactInfo{}, false
 }

144-153: Docstring vs implementation mismatch: remove all references, not just the first

The comment says “delete all references,” but the current code removes one occurrence then returns. Filter the slice to drop all matches.

Apply this diff:

 func (u *User) RemoveContactInfoFromContactPreferences(id string) {
 	// remove address from contact preferences
-	for i, addrRef := range u.ContactPreferences.SendNewsletterTo {
-		if addrRef == id {
-			u.ContactPreferences.SendNewsletterTo = append(u.ContactPreferences.SendNewsletterTo[:i], u.ContactPreferences.SendNewsletterTo[i+1:]...)
-			return
-		}
-	}
+	filtered := u.ContactPreferences.SendNewsletterTo[:0]
+	for _, addrRef := range u.ContactPreferences.SendNewsletterTo {
+		if addrRef != id {
+			filtered = append(filtered, addrRef)
+		}
+	}
+	u.ContactPreferences.SendNewsletterTo = filtered
 }
♻️ Duplicate comments (1)
pkg/user-management/types/user.go (1)

39-46: GetEmail may pick an unconfirmed or non-primary address; prioritize main login email, then confirmed emails

This mirrors an existing review. Given the PR introduces messaging, picking the wrong address is high-risk. Implement priority:

  1. If Account.Type == ACCOUNT_TYPE_EMAIL, return the contact whose Email == Account.AccountID.
  2. Else return first confirmed email.
  3. Else fall back to first email.

Apply this diff:

 func (u *User) GetEmail() (ContactInfo, error) {
-	for _, ci := range u.ContactInfos {
-		if ci.Type == CONTACT_INFO_TYPE_EMAIL {
-			return ci, nil
-		}
-	}
-	return ContactInfo{}, errors.New("email not found")
+	// 1) Prefer the main account email when account type is email
+	if u.Account.Type == ACCOUNT_TYPE_EMAIL && u.Account.AccountID != "" {
+		if ci, ok := u.FindContactInfoByTypeAndAddr(CONTACT_INFO_TYPE_EMAIL, u.Account.AccountID); ok {
+			return ci, nil
+		}
+	}
+	// 2) Fallback to the first confirmed email
+	for _, ci := range u.ContactInfos {
+		if ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.ConfirmedAt > 0 {
+			return ci, nil
+		}
+	}
+	// 3) Fallback to the first email
+	for _, ci := range u.ContactInfos {
+		if ci.Type == CONTACT_INFO_TYPE_EMAIL {
+			return ci, nil
+		}
+	}
+	return ContactInfo{}, errors.New("email not found")
 }

Run this to validate and catch other literal usages that should switch to constants:

#!/bin/bash
# Find call sites of GetEmail to assess assumptions
rg -n --type go -C2 '\bGetEmail\s*\('

# Find any remaining hard-coded type-string comparisons
rg -n --type go -P '\bType\s*==\s*"email"|\bType\s*==\s*"phone"'

# Broad scan for raw "email"/"phone" tokens in code (review results manually)
rg -n --type go -P '\b"email"\b|\b"phone"\b' -g '!**/vendor/**'
🧹 Nitpick comments (4)
pkg/user-management/types/user.go (4)

12-14: Good move: centralize contact-info type strings behind constants; minor Go naming nit

Replacing string literals with constants reduces bugs and improves consistency. Nit: Go style typically prefers PascalCase for exported identifiers (e.g., ContactInfoTypeEmail), but this file already uses ACCOUNT_TYPE_EMAIL, so keeping it consistent is reasonable.


65-73: ConfirmPhoneNumber confirms the first phone entry; consider confirming by exact number

Given SetPhoneNumber enforces a single phone, this is likely fine. If multiple phones can exist from legacy data, consider passing the target number to avoid confirming the wrong entry.

// Optional alternative signature for precision:
func (u *User) ConfirmPhoneNumber(phone string) error { /* match by type + number */ }

75-82: Prefer confirmed phone number when available

Mirror the email selection logic to reduce the chance of using an unverified number for outbound messaging.

Apply this diff:

 func (u *User) GetPhoneNumber() (ContactInfo, error) {
-	for _, ci := range u.ContactInfos {
-		if ci.Type == CONTACT_INFO_TYPE_PHONE {
-			return ci, nil
-		}
-	}
-	return ContactInfo{}, errors.New("phone number not found")
+	// Prefer confirmed phone
+	for _, ci := range u.ContactInfos {
+		if ci.Type == CONTACT_INFO_TYPE_PHONE && ci.ConfirmedAt > 0 {
+			return ci, nil
+		}
+	}
+	// Fallback to any phone
+	for _, ci := range u.ContactInfos {
+		if ci.Type == CONTACT_INFO_TYPE_PHONE {
+			return ci, nil
+		}
+	}
+	return ContactInfo{}, errors.New("phone number not found")
 }

97-107: SetContactInfoVerificationSent: also check ci.Type to avoid touching the wrong record

Use a switch and validate both type and field to be consistent with ConfirmContactInfo.

Apply this diff:

 func (u *User) SetContactInfoVerificationSent(t string, addr string) {
 	for i, ci := range u.ContactInfos {
-		if t == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
-			u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
-			return
-		} else if t == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
-			u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
-			return
-		}
+		switch t {
+		case CONTACT_INFO_TYPE_EMAIL:
+			if ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+				u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
+				return
+			}
+		case CONTACT_INFO_TYPE_PHONE:
+			if ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
+				u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
+				return
+			}
+		}
 	}
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between c7e3343 and f24e275.

📒 Files selected for processing (2)
  • pkg/study/study-service.go (3 hunks)
  • pkg/user-management/types/user.go (9 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/study/study-service.go
🧰 Additional context used
🧬 Code graph analysis (1)
pkg/user-management/types/user.go (2)
pkg/user-management/types/contact-info.go (1)
  • ContactInfo (5-12)
pkg/user-management/types/account.go (1)
  • Account (3-15)
🔇 Additional comments (3)
pkg/user-management/types/user.go (3)

25-37: AddNewEmail: Looks good

Uses the new CONTACT_INFO_TYPE_EMAIL and timestamps correctly. No concerns.


129-142: RemoveContactInfo: Correctly blocks removal of main login email and cleans preferences before deletion

The guard against removing the primary email and the in-loop removal is sound. With the proposed fix to RemoveContactInfoFromContactPreferences (below) to remove all references, this path looks good.


155-163: ReplaceContactInfoInContactPreferences: LGTM

Iterates and replaces all occurrences; consistent with expected behavior.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/user-management/types/user.go (1)

61-76: Bug: SetPhoneNumber replaces the ContactInfo object and may leave dangling references in ContactPreferences

By filtering out all phone-type entries and creating a new ContactInfo (new ID), any existing references in ContactPreferences.SendNewsletterTo will point to a non-existent ID after this call.

Fix by updating in place when a phone entry exists (preserves ID and any references). Only append when none exists:

 func (u *User) SetPhoneNumber(phone string) {
-  var newContactInfos []ContactInfo
-  for _, ci := range u.ContactInfos {
-    if ci.Type == CONTACT_INFO_TYPE_PHONE {
-      continue
-    }
-    newContactInfos = append(newContactInfos, ci)
-  }
-  contactInfo := ContactInfo{
-    ID:          primitive.NewObjectID(),
-    Type:        CONTACT_INFO_TYPE_PHONE,
-    ConfirmedAt: 0,
-    Phone:       phone,
-  }
-  u.ContactInfos = append(newContactInfos, contactInfo)
+  for i, ci := range u.ContactInfos {
+    if ci.Type == CONTACT_INFO_TYPE_PHONE {
+      // No-op if same number; otherwise update in place and reset confirmation.
+      if ci.Phone == phone {
+        return
+      }
+      u.ContactInfos[i].Phone = phone
+      u.ContactInfos[i].ConfirmedAt = 0
+      return
+    }
+  }
+  // No existing phone on record; append new ContactInfo.
+  contactInfo := ContactInfo{
+    ID:          primitive.NewObjectID(),
+    Type:        CONTACT_INFO_TYPE_PHONE,
+    ConfirmedAt: 0,
+    Phone:       phone,
+  }
+  u.ContactInfos = append(u.ContactInfos, contactInfo)
 }

If you intentionally want a new ID per phone change, then also clean up preferences:

-  u.ContactInfos = append(newContactInfos, contactInfo)
+  // Remove any dangling references to old phone IDs
+  for _, old := range u.ContactInfos {
+    if old.Type == CONTACT_INFO_TYPE_PHONE {
+      u.RemoveContactInfoFromContactPreferences(old.ID.Hex())
+    }
+  }
+  u.ContactInfos = append(newContactInfos, contactInfo)

Please confirm which behavior you want; I can adjust tests accordingly.

🧹 Nitpick comments (6)
pkg/user-management/types/user.go (6)

39-59: GetEmail selection logic is robust and resolves prior risk

Prefers the main account email when account type is email, otherwise falls back to confirmed email, then any email. This addresses the previously noted risk of selecting an unconfirmed/non-primary address.

Suggested tests to add:

  • Account.Type == email and AccountID present in ContactInfos → returns that ContactInfo.
  • Account.Type == email but AccountID absent → returns first confirmed email.
  • No confirmed emails → returns first email.
  • No emails → returns error.
    Do you want me to open a test PR with these cases?

41-44: Consider case-insensitive matching for the main email

If emails aren’t normalized, exact string compare can miss the main address. Use strings.EqualFold for email comparisons.

Apply this change in FindContactInfoByTypeAndAddr (concrete diff provided below in a related comment). Also add:

+ import "strings"

to the imports.


88-95: Prefer returning a confirmed phone number when available

For parity with GetEmail, return a confirmed phone first, then fall back.

 func (u *User) GetPhoneNumber() (ContactInfo, error) {
-  for _, ci := range u.ContactInfos {
-    if ci.Type == CONTACT_INFO_TYPE_PHONE {
-      return ci, nil
-    }
-  }
+  // Prefer confirmed phone
+  for _, ci := range u.ContactInfos {
+    if ci.Type == CONTACT_INFO_TYPE_PHONE && ci.ConfirmedAt > 0 {
+      return ci, nil
+    }
+  }
+  // Fall back to any phone
+  for _, ci := range u.ContactInfos {
+    if ci.Type == CONTACT_INFO_TYPE_PHONE {
+      return ci, nil
+    }
+  }
   return ContactInfo{}, errors.New("phone number not found")
 }

99-105: Reduce duplication with a switch on type in ConfirmContactInfo

Functionality is fine; a small refactor improves readability.

 func (u *User) ConfirmContactInfo(t string, addr string) error {
-  for i, ci := range u.ContactInfos {
-    if t == CONTACT_INFO_TYPE_EMAIL && ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
-      u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
-      return nil
-    } else if t == CONTACT_INFO_TYPE_PHONE && ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
-      u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
-      return nil
-    }
-  }
+  for i, ci := range u.ContactInfos {
+    switch t {
+    case CONTACT_INFO_TYPE_EMAIL:
+      if ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+        u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
+        return nil
+      }
+    case CONTACT_INFO_TYPE_PHONE:
+      if ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
+        u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
+        return nil
+      }
+    }
+  }
   return errors.New("contact not found")
 }

112-118: Likewise: switch on type in SetContactInfoVerificationSent

Keeps branches symmetric and easier to extend.

 func (u *User) SetContactInfoVerificationSent(t string, addr string) {
-  for i, ci := range u.ContactInfos {
-    if t == CONTACT_INFO_TYPE_EMAIL && ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
-      u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
-      return
-    } else if t == CONTACT_INFO_TYPE_PHONE && ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
-      u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
-      return
-    }
-  }
+  for i, ci := range u.ContactInfos {
+    switch t {
+    case CONTACT_INFO_TYPE_EMAIL:
+      if ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+        u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
+        return
+      }
+    case CONTACT_INFO_TYPE_PHONE:
+      if ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
+        u.ContactInfos[i].ConfirmationLinkSentAt = time.Now().Unix()
+        return
+      }
+    }
+  }
 }

124-127: Harden FindContactInfoByTypeAndAddr and support case-insensitive email compare

  • Early-continue on mismatched types to reduce duplication.
  • Use strings.EqualFold for email address comparison to avoid case pitfalls.
-func (u User) FindContactInfoByTypeAndAddr(t string, addr string) (ContactInfo, bool) {
-  for _, ci := range u.ContactInfos {
-    if t == CONTACT_INFO_TYPE_EMAIL && ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
-      return ci, true
-    } else if t == CONTACT_INFO_TYPE_PHONE && ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
-      return ci, true
-    }
-  }
-  return ContactInfo{}, false
-}
+func (u User) FindContactInfoByTypeAndAddr(t string, addr string) (ContactInfo, bool) {
+  for _, ci := range u.ContactInfos {
+    if ci.Type != t {
+      continue
+    }
+    switch t {
+    case CONTACT_INFO_TYPE_EMAIL:
+      if strings.EqualFold(ci.Email, addr) {
+        return ci, true
+      }
+    case CONTACT_INFO_TYPE_PHONE:
+      if ci.Phone == addr {
+        return ci, true
+      }
+    }
+  }
+  return ContactInfo{}, false
+}

Add import:

 import (
   "errors"
   "time"
+  "strings"
 
   "go.mongodb.org/mongo-driver/bson/primitive"
 )
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f24e275 and dba59e8.

📒 Files selected for processing (1)
  • pkg/user-management/types/user.go (9 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
pkg/user-management/types/user.go (2)
pkg/user-management/types/contact-info.go (1)
  • ContactInfo (5-12)
pkg/user-management/types/account.go (1)
  • Account (3-15)
🔇 Additional comments (4)
pkg/user-management/types/user.go (4)

12-14: Good move: introduced contact-info type constants to eliminate magic strings

This aligns with existing ACCOUNT_TYPE_EMAIL and improves consistency across the package.


29-31: Switched AddNewEmail to use CONTACT_INFO_TYPE_EMAIL

Removes a magic string and keeps type checks uniform.


78-86: ConfirmPhoneNumber: constant usage is correct

Reads cleanly and sets confirmation timestamp on the phone entry.


146-149: Correctly prevent removal of main email and clean up preferences only on success

Using ACCOUNT_TYPE_EMAIL for the guard is consistent, and moving RemoveContactInfoFromContactPreferences into the success path avoids accidental dangling changes when the contact isn’t found.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
services/participant-api/apihandlers/authentication.go (2)

713-717: Fix compile error: pass ContactInfoType instead of string to typed APIs

FindContactInfoByTypeAndAddr and SetContactInfoVerificationSent now accept ContactInfoType. Passing "email" will not compile.

Apply this diff:

-  ci, found := user.FindContactInfoByTypeAndAddr("email", req.Email)
+  ci, found := user.FindContactInfoByTypeAndAddr(userTypes.CONTACT_INFO_TYPE_EMAIL, req.Email)
   if !found {
     slog.Warn("email not found", slog.String("email", req.Email))
     c.JSON(http.StatusBadRequest, gin.H{"error": "email not found"})
     return
   }
 ...
-  user.SetContactInfoVerificationSent("email", req.Email)
+  user.SetContactInfoVerificationSent(userTypes.CONTACT_INFO_TYPE_EMAIL, req.Email)

Also applies to: 728-734


1-1: Update remaining string-typed ContactInfo call sites

I ran the sweep for string-typed call sites and uncovered the following usages that still need to be updated to the new typed APIs:

  • services/participant-api/apihandlers/authentication.go:713
    ci, found := user.FindContactInfoByTypeAndAddr("email", req.Email)

  • services/participant-api/apihandlers/user-management.go:299
    oldCI, oldFound := user.FindContactInfoByTypeAndAddr("email", user.Account.AccountID)

  • services/participant-api/apihandlers/user-management.go:330
    ci, found := user.FindContactInfoByTypeAndAddr("email", req.Email)

  • services/participant-api/apihandlers/user-management.go:340
    newCI, newFound := user.FindContactInfoByTypeAndAddr("email", req.Email)

  • services/participant-api/apihandlers/authentication.go:728
    user.SetContactInfoVerificationSent("email", req.Email)

No matches were found for ConfirmContactInfo("…").

Please refactor these call sites to use the strongly-typed ContactInfo API (e.g. passing the ContactType.Email constant or equivalent) instead of raw string literals. This will ensure consistency and catch any mismatches at compile time.

♻️ Duplicate comments (2)
pkg/study/studyengine/sender/sender.go (1)

86-98: Thanks for addressing the prior panic on user.Profiles[0].

The length guard before indexing resolves the panic highlighted in a previous review.

pkg/user-management/types/user.go (1)

43-63: GetEmail selection logic now matches previous recommendation — LGTM

Prefers the account’s main email, then any confirmed email, then any email; returns an error otherwise. This aligns with the prior review guidance.

🧹 Nitpick comments (16)
pkg/study/studyengine/actions.go (2)

653-674: Constrain and sanitize dynamic payload to prevent accidental PII/size blowups.

You’re flattening participant flags, linking codes, and arbitrary event payload into an email-template payload. Two recommendations:

  • Skip empty keys and clamp overly long values to avoid accidentally dumping large objects into outgoing emails.
  • Keep the current namespacing; it’s good for avoiding collisions.

Apply a minimal guard:

 func getExtraPayload(pState studyTypes.Participant, event StudyEvent) map[string]string {
   payload := map[string]string{
     "studyKey": event.StudyKey,
   }

   // include participant flags into payload:
   for k, v := range pState.Flags {
-    payload["flags."+k] = v
+    if strings.TrimSpace(k) == "" {
+      continue
+    }
+    vv := v
+    const maxValueLen = 2048
+    if len(vv) > maxValueLen {
+      vv = vv[:maxValueLen]
+    }
+    payload["flags."+k] = vv
   }

   // include linking codes into payload
   for k, v := range pState.LinkingCodes {
-    payload["linkingCodes."+k] = v
+    if strings.TrimSpace(k) == "" {
+      continue
+    }
+    vv := v
+    const maxValueLen = 2048
+    if len(vv) > maxValueLen {
+      vv = vv[:maxValueLen]
+    }
+    payload["linkingCodes."+k] = vv
   }

   // include event payload into message (template) payload
   for k, v := range event.Payload {
-    payload["eventData."+k] = fmt.Sprintf("%v", v)
+    if strings.TrimSpace(k) == "" {
+      continue
+    }
+    vv := fmt.Sprintf("%v", v)
+    const maxValueLen = 2048
+    if len(vv) > maxValueLen {
+      vv = vv[:maxValueLen]
+    }
+    payload["eventData."+k] = vv
   }

   return payload
 }

If some values genuinely need to exceed 2 KB, adjust maxValueLen accordingly.


676-739: Make language override truly optional and validate message type.

Today, failure to resolve the optional language argument aborts the action. Also, trim/validate messageType to avoid sending with an empty template key.

 func sendMessageNow(action studyTypes.Expression, oldState ActionData, event StudyEvent) (newState ActionData, err error) {
   newState = oldState
@@
   arg1, err := EvalContext.ExpressionArgResolver(action.Data[0])
   if err != nil {
     return newState, err
   }
 
-  messageType, ok1 := arg1.(string)
-  if !ok1 {
+  messageType, ok1 := arg1.(string)
+  messageType = strings.TrimSpace(messageType)
+  if !ok1 || messageType == "" {
     return newState, errors.New("could not parse arguments")
   }
@@
-  if len(action.Data) > 1 {
-    arg2, err := EvalContext.ExpressionArgResolver(action.Data[1])
-    if err != nil {
-      return newState, err
-    }
-    languageOverride, ok1 = arg2.(string)
-    if !ok1 {
-      slog.Debug("could not parse language override")
-    }
-  }
+  if len(action.Data) > 1 {
+    if arg2, err := EvalContext.ExpressionArgResolver(action.Data[1]); err == nil {
+      if v, ok := arg2.(string); ok {
+        languageOverride = v
+      } else {
+        slog.Debug("SEND_MESSAGE_NOW: language override not a string; skipping")
+      }
+    } else {
+      slog.Debug("SEND_MESSAGE_NOW: failed to resolve language override; skipping", slog.String("error", err.Error()))
+    }
+  }

Consider adding unit tests for:

  • missing participantIDForConfidentialResponses
  • empty/whitespace messageType
  • language override present/invalid/resolve-error
pkg/study/studyengine/sender/sender.go (7)

114-115: Prevent extraPayload from overriding reserved keys (profileAlias/profileId/loginToken).

Protect invariants while still allowing rich template data.

-  maps.Copy(payload, extraPayload)
+  for k, v := range extraPayload {
+    switch k {
+    case "profileAlias", "profileId", "loginToken":
+      continue
+    }
+    payload[k] = v
+  }

If some reserved key overrides are desired, we can allowlist them explicitly per messageType.


117-121: Set a safe default language before applying override.

Avoids sending with an empty lang when PreferredLanguage is unset.

-  lang := user.Account.PreferredLanguage
-  if opts.LanguageOverride != "" {
-    lang = opts.LanguageOverride
-  }
+  lang := user.Account.PreferredLanguage
+  if lang == "" {
+    lang = "en"
+  }
+  if opts.LanguageOverride != "" {
+    lang = opts.LanguageOverride
+  }

60-63: Initialization check: clarify intent and coverage.

You currently check studyDB/participantUserDB/messagingDB but not globalInfosDB (used by token creation). With the guard above, keeping globalInfosDB optional is fine. Consider removing messagingDB from this check since it’s not referenced here (the email-sending package uses its own wiring).


145-160: Nit: rename getTemploginToken → getTempLoginToken.

Improves readability and follows camel casing.

- func (s *StudyMessageSender) getTemploginToken(instanceID string, user umTypes.User, studyKey string) (string, error) {
+ func (s *StudyMessageSender) getTempLoginToken(instanceID string, user umTypes.User, studyKey string) (string, error) {
@@
-   loginToken, err := s.getTemploginToken(instanceID, user, studyKey)
+   loginToken, err := s.getTempLoginToken(instanceID, user, studyKey)

Also applies to: 107-107


29-32: Config defaulting: treat zero LoginTokenTTL as “disabled” or set a sensible default.

Zero TTL means tokens expire immediately. Either document that 0 disables token issuance (and gate on >0 as suggested) or default it in NewStudyMessageSender to a safe value (e.g., 15m).

Would you like me to push a small defaulting change in NewStudyMessageSender?


51-59: Minor: method doc and dependency minimality.

Good docstring. Since this method doesn’t directly use messagingDB, consider dropping it from the struct to reduce coupling—or keep it if you expect to use it shortly.


78-85: Email presence handling is fine; consider soft-bounce path.

GetEmail error path is logged and returned. If desired, we can add a study-specific “no-email” report or metric to aid monitoring.

pkg/user-management/types/contact-info.go (1)

5-12: Optional: enforce “one-of” invariant and trim payload noise

To keep documents tidy and invariants clear:

  • Only one of Email or Phone should be set per ContactInfo; consider validating this at constructors/mutators.
  • Consider adding ,omitempty to Email and Phone to avoid storing empty strings.

Apply this diff:

 type ContactInfo struct {
   ID                     primitive.ObjectID `bson:"_id,omitempty" json:"id"`
   Type                   ContactInfoType    `bson:"type" json:"type"`
   ConfirmedAt            int64              `bson:"confirmedAt" json:"confirmedAt"`
   ConfirmationLinkSentAt int64              `bson:"confirmationLinkSentAt" json:"confirmationLinkSentAt"`
-  Email                  string             `bson:"email" json:"email"`
-  Phone                  string             `bson:"phone" json:"phone"`
+  Email                  string             `bson:"email,omitempty" json:"email,omitempty"`
+  Phone                  string             `bson:"phone,omitempty" json:"phone,omitempty"`
 }

If helpful, I can wire a small validator that runs where ContactInfo instances are created/updated.

services/participant-api/apihandlers/authentication.go (2)

823-835: Validate token “type” and return 400 for unknown/absent contact; avoid 500s

Casting cType to ContactInfoType is fine, but if the token carries an unsupported value, ConfirmContactInfo just returns “contact not found,” which you map to 500. Treat this as client error (400) and short-circuit on unknown types.

Apply this diff:

   cType, ok1 := tokenInfos.Info["type"]
   email, ok2 := tokenInfos.Info["email"]
   if !ok1 || !ok2 {
     slog.Error("missing type or email in token infos", slog.String("error", "missing type or email in token infos"), slog.String("instanceID", tokenInfos.InstanceID), slog.String("userID", tokenInfos.UserID))
     c.JSON(http.StatusBadRequest, gin.H{"error": "missing type or email in token infos"})
     return
   }

-  if err := user.ConfirmContactInfo(userTypes.ContactInfoType(cType), email); err != nil {
-    slog.Error("failed to confirm contact info", slog.String("error", err.Error()), slog.String("instanceID", tokenInfos.InstanceID), slog.String("userID", tokenInfos.UserID))
-    c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to confirm contact info"})
-    return
-  }
+  typed := userTypes.ContactInfoType(cType)
+  if typed != userTypes.CONTACT_INFO_TYPE_EMAIL && typed != userTypes.CONTACT_INFO_TYPE_PHONE {
+    slog.Warn("unsupported contact info type from token", slog.String("type", cType))
+    c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported contact info type"})
+    return
+  }
+  if _, ok := user.FindContactInfoByTypeAndAddr(typed, email); !ok {
+    slog.Warn("contact not found for token", slog.String("type", cType), slog.String("addr", email))
+    c.JSON(http.StatusBadRequest, gin.H{"error": "contact not found"})
+    return
+  }
+  if err := user.ConfirmContactInfo(typed, email); err != nil {
+    slog.Error("failed to confirm contact info", slog.String("error", err.Error()), slog.String("instanceID", tokenInfos.InstanceID), slog.String("userID", tokenInfos.UserID))
+    c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to confirm contact info"})
+    return
+  }

This preserves the 500 path for real persistence failures but maps bad token content to 400.


494-497: Nit: avoid string literal drift for “email” key

For lastOTP, prefer reusing the canonical value to prevent typos and make future renames safer.

Apply this diff:

-  lastOTP := map[string]int64{
-    "email": time.Now().Unix(),
-  }
+  lastOTP := map[string]int64{
+    string(userTypes.CONTACT_INFO_TYPE_EMAIL): time.Now().Unix(),
+  }
pkg/user-management/types/user.go (4)

12-17: Typed ContactInfoType + constants look good; consider centralized validation

The enum-like constants eliminate magic strings. Add a tiny helper for validation to keep checks consistent.

Apply this diff:

 type ContactInfoType string

 const (
   CONTACT_INFO_TYPE_EMAIL ContactInfoType = "email"
   CONTACT_INFO_TYPE_PHONE ContactInfoType = "phone"
 )
+
+func (t ContactInfoType) IsValid() bool {
+  return t == CONTACT_INFO_TYPE_EMAIL || t == CONTACT_INFO_TYPE_PHONE
+}

33-41: Prevent duplicate email entries on AddNewEmail

If called multiple times with the same addr, this appends duplicates. Guard against that to keep ContactInfos clean.

Apply this diff:

 func (u *User) AddNewEmail(addr string, confirmed bool) {
+  // De-duplicate on address
+  for _, ci := range u.ContactInfos {
+    if ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+      if confirmed && ci.ConfirmedAt == 0 {
+        // upgrade existing to confirmed
+        // Note: use ReplaceUser to persist after caller mutates u
+        // Caller should persist u after this call.
+      }
+      return
+    }
+  }
   contactInfo := ContactInfo{
     ID:          primitive.NewObjectID(),
     Type:        CONTACT_INFO_TYPE_EMAIL,
     ConfirmedAt: 0,
     Email:       addr,
   }

If you prefer, I can wire the “upgrade to confirmed” inline by switching to index-based update.


101-135: Minor: reduce duplication across typed contact-info lookups

ConfirmContactInfo, SetContactInfoVerificationSent, and FindContactInfoByTypeAndAddr repeat the same t/type/email-or-phone checks. A small switch-based matcher would simplify and de-risk future additions (e.g., more ContactInfoType values).

Example refactor for one function:

 func (u *User) ConfirmContactInfo(t ContactInfoType, addr string) error {
-  for i, ci := range u.ContactInfos {
-    if t == CONTACT_INFO_TYPE_EMAIL && ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
-      u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
-      return nil
-    } else if t == CONTACT_INFO_TYPE_PHONE && ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
-      u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
-      return nil
-    }
-  }
+  for i, ci := range u.ContactInfos {
+    switch t {
+    case CONTACT_INFO_TYPE_EMAIL:
+      if ci.Type == CONTACT_INFO_TYPE_EMAIL && ci.Email == addr {
+        u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
+        return nil
+      }
+    case CONTACT_INFO_TYPE_PHONE:
+      if ci.Type == CONTACT_INFO_TYPE_PHONE && ci.Phone == addr {
+        u.ContactInfos[i].ConfirmedAt = time.Now().Unix()
+        return nil
+      }
+    }
+  }
   return errors.New("contact not found")
 }

If you like this pattern, I can apply it consistently to SetContactInfoVerificationSent and FindContactInfoByTypeAndAddr.


126-135: Pointer vs value receiver consistency

FindContactInfoByTypeAndAddr now uses a value receiver while most other methods on User use pointers. It works (slices aren’t deep-copied), but keeping receiver style consistent improves readability.

Apply this diff:

-func (u User) FindContactInfoByTypeAndAddr(t ContactInfoType, addr string) (ContactInfo, bool) {
+func (u *User) FindContactInfoByTypeAndAddr(t ContactInfoType, addr string) (ContactInfo, bool) {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between dba59e8 and e585ccf.

📒 Files selected for processing (7)
  • pkg/study/studyengine/actions.go (2 hunks)
  • pkg/study/studyengine/expressions.go (2 hunks)
  • pkg/study/studyengine/sender/sender.go (1 hunks)
  • pkg/user-management/types/contact-info.go (1 hunks)
  • pkg/user-management/types/user.go (6 hunks)
  • services/management-api/init.go (1 hunks)
  • services/participant-api/apihandlers/authentication.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • services/management-api/init.go
  • pkg/study/studyengine/expressions.go
🧰 Additional context used
🧬 Code graph analysis (5)
pkg/user-management/types/contact-info.go (1)
pkg/user-management/types/user.go (1)
  • ContactInfoType (12-12)
services/participant-api/apihandlers/authentication.go (1)
pkg/user-management/types/user.go (1)
  • ContactInfoType (12-12)
pkg/study/studyengine/actions.go (3)
pkg/study/types/participant.go (1)
  • Participant (14-26)
pkg/study/studyengine/types.go (5)
  • StudyEvent (76-85)
  • ActionData (57-60)
  • CurrentStudyEngine (28-28)
  • EvalContext (88-91)
  • SendOptions (94-97)
pkg/study/studyengine/helpers.go (1)
  • Now (13-13)
pkg/user-management/types/user.go (2)
pkg/user-management/types/contact-info.go (1)
  • ContactInfo (5-12)
pkg/user-management/types/account.go (1)
  • Account (3-15)
pkg/study/studyengine/sender/sender.go (6)
pkg/study/studyengine/types.go (1)
  • SendOptions (94-97)
pkg/user-management/types/account.go (1)
  • Account (3-15)
pkg/messaging/email-sending/send.go (1)
  • SendInstantEmailByTemplate (61-110)
pkg/user-management/types/user.go (1)
  • User (19-27)
pkg/user-management/types/temptoken.go (2)
  • TempToken (19-27)
  • TOKEN_PURPOSE_SURVEY_LOGIN (13-13)
pkg/user-management/utils/temp-tokens.go (1)
  • GetExpirationTime (33-35)
🔇 Additional comments (5)
pkg/study/studyengine/actions.go (1)

63-64: Wiring looks correct for SEND_MESSAGE_NOW.

Dispatching to sendMessageNow is consistent with the existing action switch.

pkg/study/studyengine/sender/sender.go (1)

3-17: No action needed: Go version supports maps.Copy

The project’s go.mod specifies Go 1.23.0, which is above the 1.21 minimum required for maps.Copy, so no compatibility changes are necessary.

pkg/user-management/types/contact-info.go (1)

7-7: Good move to strong typing for ContactInfo.Type

Switching from string to ContactInfoType improves type-safety and prevents accidental misuse while keeping BSON/JSON wire format unchanged (underlying type is string). No data migration should be required.

pkg/user-management/types/user.go (2)

150-154: Good: prevent removal of the main login email

Blocks deleting the primary address when the account type is email; also updates contact preferences only upon successful removal. Solid guardrail.


146-170: RemoveContactInfoFromContactPreferences covers all current ID-based fields

Verified that ContactPreferences only contains one slice of contact IDs—SendNewsletterTo—and two boolean flags (SubscribedToNewsletter, SubscribedToWeekly). The existing RemoveContactInfoFromContactPreferences method already removes any matching ID from SendNewsletterTo, so no additional cleanup is needed at this time.

• pkg/user-management/types/contact-preferences.go: ContactPreferences defines only
SendNewsletterTo []string (cleaned by RemoveContactInfoFromContactPreferences)
SubscribedToNewsletter, SubscribedToWeekly (booleans, no ID references)
• pkg/user-management/types/user.go: RemoveContactInfoFromContactPreferences handles SendNewsletterTo

No changes required—this comment is resolved. Please remember to update this method when new slice-based preferences (e.g., SMS or push ID lists) are added.

Comment thread pkg/study/studyengine/sender/sender.go Outdated
…fo type in authentication and user management handlers

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
services/participant-api/apihandlers/user-management.go (2)

333-344: Avoid a redundant lookup and reuse the result variable

You can reuse the first lookup result and only re-query when you actually add a new email. This removes one extra FindContactInfoByTypeAndAddr call on the “found” path and simplifies variable usage later.

Apply:

- ci, found := user.FindContactInfoByTypeAndAddr(
-   userTypes.CONTACT_INFO_TYPE_EMAIL,
-   req.Email,
- )
-if found {
+ newCI, newFound := user.FindContactInfoByTypeAndAddr(
+   userTypes.CONTACT_INFO_TYPE_EMAIL,
+   req.Email,
+ )
+if newFound {
   // new email already confirmed
-  if ci.ConfirmedAt > 0 {
-    user.Account.AccountConfirmedAt = ci.ConfirmedAt
+  if newCI.ConfirmedAt > 0 {
+    user.Account.AccountConfirmedAt = newCI.ConfirmedAt
   }
 } else {
   user.AddNewEmail(req.Email, false)
+  // reload to get the ID of the newly added contact
+  newCI, newFound = user.FindContactInfoByTypeAndAddr(
+    userTypes.CONTACT_INFO_TYPE_EMAIL,
+    req.Email,
+  )
 }

Note: This keeps newCI/newFound in scope for subsequent use (e.g., in ReplaceContactInfoInContactPreferences), and removes an unnecessary extra lookup on the “found” branch.


346-349: Remove now-redundant second lookup (handled in the earlier block)

If you adopt the previous refactor, this second FindContactInfoByTypeAndAddr block becomes unnecessary; you’ll already have newCI/newFound populated.

Apply:

- newCI, newFound := user.FindContactInfoByTypeAndAddr(
-   userTypes.CONTACT_INFO_TYPE_EMAIL,
-   req.Email,
- )
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between e585ccf and 0a45045.

📒 Files selected for processing (2)
  • services/participant-api/apihandlers/authentication.go (3 hunks)
  • services/participant-api/apihandlers/user-management.go (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • services/participant-api/apihandlers/authentication.go
🧰 Additional context used
🧬 Code graph analysis (1)
services/participant-api/apihandlers/user-management.go (2)
pkg/user-management/types/user.go (1)
  • CONTACT_INFO_TYPE_EMAIL (15-15)
pkg/user-management/types/account.go (1)
  • Account (3-15)
🔇 Additional comments (1)
services/participant-api/apihandlers/user-management.go (1)

299-302: Migration verified: no raw "email" usages remain

I ran the ripgrep searches to look for any calls to FindContactInfoByTypeAndAddr passing a string literal "email". All instances now use the CONTACT_INFO_TYPE_EMAIL constant:

  • services/participant-api/apihandlers/user-management.go (lines 299–302, 333–335, 346–348)
  • services/participant-api/apihandlers/authentication.go (lines 713–715)
  • pkg/user-management/types/user.go (line 46)

There are no remaining literal "email" arguments. The migration to typed ContactInfoType is complete. LGTM.

…tionally generate login token in SendInstantStudyEmail function

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
pkg/user-management/types/contact-info.go (1)

5-12: Enforce invariants: ensure address matches type (email vs. phone), and only one is set.

With a typed Type, we should validate that:

  • Type=email => Email non-empty, Phone empty
  • Type=phone => Phone non-empty, Email empty

This prevents mixed/invalid documents at the edges (handlers, repo layer).

Add a lightweight validator (outside this file) and call it in create/update paths:

func (c ContactInfo) Validate() error {
    switch c.Type {
    case CONTACT_INFO_TYPE_EMAIL:
        if c.Email == "" { return fmt.Errorf("email required for type=email") }
        if c.Phone != "" { return fmt.Errorf("phone must be empty for type=email") }
    case CONTACT_INFO_TYPE_PHONE:
        if c.Phone == "" { return fmt.Errorf("phone required for type=phone") }
        if c.Email != "" { return fmt.Errorf("email must be empty for type=phone") }
    default:
        return fmt.Errorf("unsupported contact info type: %q", c.Type)
    }
    return nil
}

I can wire this into the repository/service layer and add unit tests covering JSON/BSON round-trips and validation. Want me to push a patch?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0a45045 and 622eb9b.

📒 Files selected for processing (3)
  • pkg/study/studyengine/actions.go (2 hunks)
  • pkg/study/studyengine/sender/sender.go (1 hunks)
  • pkg/user-management/types/contact-info.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • pkg/study/studyengine/sender/sender.go
  • pkg/study/studyengine/actions.go
🔇 Additional comments (2)
pkg/user-management/types/contact-info.go (2)

7-7: ContactInfoType is already a string alias; backward-compatibility preserved

Verified that in pkg/user-management/types/user.go:

  • Line 12: type ContactInfoType string
  • Lines 15–16: CONTACT_INFO_TYPE_EMAIL ContactInfoType = "email" and CONTACT_INFO_TYPE_PHONE ContactInfoType = "phone"
  • No custom JSON/BSON marshalers defined for ContactInfoType (default string alias marshaling emits strings)

No further changes required.


10-11: Ensure JSON shape for ContactInfo email/phone remains stable

We’re exposing ContactInfo (with both Email and Phone fields) in our user‐management APIs. Making the JSON tags omitempty will drop the empty field entirely, which can break downstream clients that expect those keys to always be present (even if empty). To keep MongoDB records clean while preserving the API contract, we should only omit empty values in BSON but always emit both fields in JSON.

Key locations to check and update:

  • pkg/user-management/types/contact-info.go (lines 10–11) – the Email / Phone tags
  • pkg/user-management/types/user.go – ContactInfos []ContactInfo is served in all user payloads
  • services/management-api/apihandlers/user-management.go – serializes User (and thus ContactInfo) to JSON
  • services/participant-api/apihandlers/user-management.go – same serialization in the participant API

Apply this diff:

--- a/pkg/user-management/types/contact-info.go
+++ b/pkg/user-management/types/contact-info.go
@@ -7,7 +7,7 @@ type ContactInfo struct {
 	ConfirmationLinkSentAt int64              `bson:"confirmationLinkSentAt" json:"confirmationLinkSentAt"`
-	Email                  string             `bson:"email,omitempty" json:"email,omitempty"`
-	Phone                  string             `bson:"phone,omitempty" json:"phone,omitempty"`
+	Email                  string             `bson:"email,omitempty" json:"email"`
+	Phone                  string             `bson:"phone,omitempty" json:"phone"`
 }

@phev8 phev8 requested a review from Copilot August 26, 2025 07:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements immediate study-triggered email functionality by adding a new SEND_MESSAGE_NOW action to the study engine and creating a pluggable message sender architecture.

  • Adds StudyMessageSender interface for immediate email sending from study rules
  • Creates StudyMessageSender implementation with template-based email support and optional login tokens
  • Refactors contact info handling to use typed constants instead of string literals

Reviewed Changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
services/participant-api/init.go Initializes study message sender and reorders initialization sequence
services/participant-api/apihandlers/*.go Updates contact info methods to use typed constants
services/management-api/init.go Passes nil message sender (not used in this service)
pkg/user-management/types/*.go Introduces typed ContactInfoType and new GetEmail method
pkg/study/studyengine/*.go Adds message sender interface and SEND_MESSAGE_NOW action
pkg/study/studyengine/sender/sender.go Implements message sender with email template support
pkg/study/study-service.go Registers message sender with study engine
pkg/messaging/email-sending/utils.go Fixes parameter order and adds structured logging
jobs/*/init.go Updates study service initialization calls

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment thread pkg/user-management/types/user.go
Comment thread pkg/messaging/email-sending/utils.go
Comment thread pkg/study/studyengine/actions.go
@phev8

phev8 commented Aug 27, 2025

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no bugs!


@phev8 phev8 merged commit 381d0bc into main Aug 28, 2025
2 checks passed
@phev8 phev8 deleted the study-engine/send-message-now branch August 28, 2025 08:02
@coderabbitai coderabbitai Bot mentioned this pull request Sep 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants