Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/org/maproulette/Config.scala
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ class Config @Inject() (implicit val configuration: Configuration) {
this.config
.getOptional[String](Config.KEY_TASK_LOCK_EXPIRY)
.getOrElse(Config.DEFAULT_TASK_LOCK_EXPIRY)
lazy val taskLockExpiryReminderLeadTime: String =
this.config
.getOptional[String](Config.KEY_SCHEDULER_TASK_LOCK_EXPIRY_REMINDER_LEAD_TIME)
.getOrElse(Config.DEFAULT_TASK_LOCK_EXPIRY_REMINDER_LEAD_TIME)
lazy val taskScoreFixed: Int =
this.config
.getOptional[Int](Config.KEY_TASK_SCORE_FIXED)
Expand Down Expand Up @@ -344,6 +348,11 @@ object Config {
s"$SUB_GROUP_SCHEDULER.notifications.digestEmail.interval"
val KEY_SCHEDULER_NOTIFICATION_DIGEST_EMAIL_START =
s"$SUB_GROUP_SCHEDULER.notifications.digestEmail.startTime"
val KEY_SCHEDULER_TASK_LOCK_EXPIRY_REMINDER_INTERVAL =
s"$SUB_GROUP_SCHEDULER.notifications.taskLockExpiryReminder.interval"
val KEY_SCHEDULER_TASK_LOCK_EXPIRY_REMINDER_LEAD_TIME =
s"$SUB_GROUP_SCHEDULER.notifications.taskLockExpiryReminder.leadTime"
val DEFAULT_TASK_LOCK_EXPIRY_REMINDER_LEAD_TIME = "10 minutes"
val KEY_SCHEDULER_SNAPSHOT_USER_METRICS = s"$SUB_GROUP_SCHEDULER.userMetricsSnapshot.interval"
val KEY_SCHEDULER_SNAPSHOT_USER_METRICS_START =
s"$SUB_GROUP_SCHEDULER.userMetricsSnapshot.startTime"
Expand Down
8 changes: 6 additions & 2 deletions app/org/maproulette/framework/model/UserNotification.scala
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ object UserNotification extends CommonField {
val NOTIFICATION_TYPE_CHALLENGE_COMMENT_NAME = "Challenge Comment"
val NOTIFICATION_TYPE_CHALLENGE_UNLOCK_REQUESTED = 15
val NOTIFICATION_TYPE_CHALLENGE_UNLOCK_REQUESTED_NAME = "Challenge Unlock Requested"
val NOTIFICATION_TYPE_TASK_UNLOCK_WARNING = 16
val NOTIFICATION_TYPE_TASK_UNLOCK_WARNING_NAME = "Task Lock Expiring Soon"

val notificationTypeMap = Map(
NOTIFICATION_TYPE_SYSTEM -> NOTIFICATION_TYPE_SYSTEM_NAME,
Expand All @@ -97,7 +99,8 @@ object UserNotification extends CommonField {
NOTIFICATION_TYPE_FOLLOW -> NOTIFICATION_TYPE_FOLLOW_NAME,
NOTIFICATION_TYPE_MAPPER_CHALLENGE_COMPLETED -> NOTIFICATION_TYPE_MAPPER_CHALLENGE_COMPLETED_NAME,
NOTIFICATION_TYPE_CHALLENGE_COMMENT -> NOTIFICATION_TYPE_CHALLENGE_COMMENT_NAME,
NOTIFICATION_TYPE_CHALLENGE_UNLOCK_REQUESTED -> NOTIFICATION_TYPE_CHALLENGE_UNLOCK_REQUESTED_NAME
NOTIFICATION_TYPE_CHALLENGE_UNLOCK_REQUESTED -> NOTIFICATION_TYPE_CHALLENGE_UNLOCK_REQUESTED_NAME,
NOTIFICATION_TYPE_TASK_UNLOCK_WARNING -> NOTIFICATION_TYPE_TASK_UNLOCK_WARNING_NAME
)

val NOTIFICATION_IGNORE = 0 // ignore notification
Expand All @@ -122,7 +125,8 @@ case class NotificationSubscriptions(
val follow: Int,
val metaReview: Int,
val reviewCount: Int,
val revisionCount: Int
val revisionCount: Int,
val taskUnlockWarning: Int = UserNotification.NOTIFICATION_EMAIL_NONE
)
object NotificationSubscriptions {
implicit val notificationSubscriptionReads: Reads[NotificationSubscriptions] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,18 @@ class NotificationSubscriptionRepository @Inject() (
"""
|INSERT INTO user_notification_subscriptions (user_id, system, mention, review_approved,
| review_rejected, review_again, challenge_completed, team,
| follow, meta_review, review_count, revision_count)
| follow, meta_review, review_count, revision_count,
| task_unlock_warning)
|VALUES({userId}, {system}, {mention}, {reviewApproved}, {reviewRejected}, {reviewAgain},
| {challengeCompleted}, {team}, {follow}, {metaReview}, {reviewCount}, {revisionCount})
| {challengeCompleted}, {team}, {follow}, {metaReview}, {reviewCount}, {revisionCount},
| {taskUnlockWarning})
|ON CONFLICT (user_id) DO
|UPDATE SET system=EXCLUDED.system, mention=EXCLUDED.mention,
| review_approved=EXCLUDED.review_approved, review_rejected=EXCLUDED.review_rejected,
| review_again=EXCLUDED.review_again, challenge_completed=EXCLUDED.challenge_completed,
| review_again=EXCLUDED.review_again, challenge_completed=EXCLUDED.challenge_completed,
| team=EXCLUDED.team, follow=EXCLUDED.follow, meta_review=EXCLUDED.meta_review,
| review_count=EXCLUDED.review_count, revision_count=EXCLUDED.revision_count
| review_count=EXCLUDED.review_count, revision_count=EXCLUDED.revision_count,
| task_unlock_warning=EXCLUDED.task_unlock_warning
""".stripMargin
).on(
Symbol("userId") -> userId,
Expand All @@ -99,7 +102,8 @@ class NotificationSubscriptionRepository @Inject() (
Symbol("follow") -> subscriptions.follow,
Symbol("metaReview") -> subscriptions.metaReview,
Symbol("reviewCount") -> subscriptions.reviewCount,
Symbol("revisionCount") -> subscriptions.revisionCount
Symbol("revisionCount") -> subscriptions.revisionCount,
Symbol("taskUnlockWarning") -> subscriptions.taskUnlockWarning
)
.executeUpdate()
}
Expand All @@ -121,9 +125,11 @@ object NotificationSubscriptionRepository {
get[Int]("follow") ~
get[Int]("meta_review") ~
get[Int]("review_count") ~
get[Int]("revision_count") map {
get[Int]("revision_count") ~
get[Int]("task_unlock_warning") map {
case id ~ userId ~ system ~ mention ~ reviewApproved ~ reviewRejected ~ reviewAgain ~
challengeCompleted ~ team ~ follow ~ metaReview ~ reviewCount ~ revisionCount =>
challengeCompleted ~ team ~ follow ~ metaReview ~ reviewCount ~ revisionCount ~
taskUnlockWarning =>
NotificationSubscriptions(
id,
userId,
Expand All @@ -137,7 +143,8 @@ object NotificationSubscriptionRepository {
follow,
metaReview,
reviewCount,
revisionCount
revisionCount,
taskUnlockWarning
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class NotificationService @Inject() (
UserNotification.NOTIFICATION_EMAIL_NONE,
UserNotification.NOTIFICATION_EMAIL_NONE,
UserNotification.NOTIFICATION_EMAIL_NONE,
UserNotification.NOTIFICATION_EMAIL_NONE,
UserNotification.NOTIFICATION_EMAIL_NONE
)
}
Expand Down
7 changes: 7 additions & 0 deletions app/org/maproulette/jobs/Scheduler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ class Scheduler @Inject() (
Config.KEY_SCHEDULER_REBUILD_DIRTY_TILE_CELLS_INTERVAL
)

schedule(
"sendTaskLockExpiryReminders",
"Sending Task Lock Expiry Reminders",
1.minute,
Config.KEY_SCHEDULER_TASK_LOCK_EXPIRY_REMINDER_INTERVAL
)

scheduleAtTime(
"sendCountNotificationDailyEmails",
"Sending Count Notification Daily Emails",
Expand Down
71 changes: 71 additions & 0 deletions app/org/maproulette/jobs/SchedulerActor.scala
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ class SchedulerActor @Inject() (
this.handleArchiveChallenges(action)
case RunJob("rebuildDirtyTileCells", action) =>
this.rebuildDirtyTileCells(action)
case RunJob("sendTaskLockExpiryReminders", action) =>
this.sendTaskLockExpiryReminders(action)
}

/** Leaf cells recomputed per drain transaction. */
Expand Down Expand Up @@ -632,6 +634,75 @@ class SchedulerActor @Inject() (
)
}

/**
* Emails users whose held task locks are within `taskLockExpiryReminderLeadTime`
* of being cleaned up by `cleanLocks`. Only fires for users who have opted in
* via the `task_unlock_warning` subscription. Marks each lock as reminded in
* the same UPDATE that selects it, so overlapping runs cannot double-send.
*/
def sendTaskLockExpiryReminders(action: String): Unit = {
val start = System.currentTimeMillis
logger.info(s"Scheduled Task '$action': Starting run")

val reminders = db.withTransaction { implicit c =>
SQL(s"""
|UPDATE locked
|SET reminder_sent_at = NOW()
|WHERE id IN (
| SELECT l.id FROM locked l
| JOIN user_notification_subscriptions s ON s.user_id = l.user_id
| JOIN users u ON u.id = l.user_id
| WHERE l.item_type = ${org.maproulette.data.Actions.ITEM_TYPE_TASK}
| AND l.reminder_sent_at IS NULL
| AND AGE(NOW(), l.locked_time) >
| ('${config.taskLockExpiry}'::INTERVAL - '${config.taskLockExpiryReminderLeadTime}'::INTERVAL)
| AND s.task_unlock_warning = ${UserNotification.NOTIFICATION_EMAIL_IMMEDIATE}
| AND u.email IS NOT NULL AND u.email <> ''
|)
|RETURNING
| locked.user_id,
| locked.item_id AS task_id,
| (SELECT email FROM users WHERE id = locked.user_id) AS email,
| (SELECT c.name FROM tasks t JOIN challenges c ON c.id = t.parent_id
| WHERE t.id = locked.item_id) AS challenge_name,
| GREATEST(
| CEIL(EXTRACT(EPOCH FROM
| ('${config.taskLockExpiry}'::INTERVAL - AGE(NOW(), locked.locked_time))
| ) / 60)::INT,
| 0
| ) AS minutes_until_unlock
""".stripMargin)
.as((
long("user_id") ~
long("task_id") ~
get[Option[String]]("email") ~
get[Option[String]]("challenge_name") ~
int("minutes_until_unlock")
).map { case userId ~ taskId ~ email ~ challengeName ~ minutesUntilUnlock =>
(userId, taskId, email, challengeName, minutesUntilUnlock)
}.*)
}

reminders.foreach { case (_, taskId, email, challengeName, minutesUntilUnlock) =>
try {
email match {
case Some(address) if address.nonEmpty =>
this.emailProvider
.emailTaskUnlockWarning(address, taskId, challengeName, minutesUntilUnlock.toLong)
case _ => // no address; nothing to do
}
} catch {
case e: Exception =>
logger.error(s"Failed to send task lock expiry reminder for task $taskId: $e")
}
}

val totalTime = System.currentTimeMillis - start
logger.info(
s"Scheduled Task '$action': Finished run. Time spent: ${String.format("%1d", totalTime)}ms. ${reminders.size} reminder(s) sent"
)
}

def sendImmediateNotificationEmails(action: String) = {
val start = System.currentTimeMillis
logger.info(s"Scheduled Task '$action': Starting run")
Expand Down
29 changes: 29 additions & 0 deletions app/org/maproulette/provider/EmailProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,35 @@ class EmailProvider @Inject() (mailerClient: MailerClient, config: Config) {
mailerClient.send(email)
}

def emailTaskUnlockWarning(
toAddress: String,
taskId: Long,
challengeName: Option[String],
minutesUntilUnlock: Long
) = {
val emailSubject = s"Your MapRoulette task lock will expire soon"
val challengeLine = challengeName match {
case Some(name) => s"\nChallenge: ${name}"
case None => ""
}
val taskLink = s"${config.getPublicOrigin.get}/task/${taskId}"
val emailBody = s"""
|Your lock on a MapRoulette task is going to expire in approximately
|${minutesUntilUnlock} minute(s). Once it expires, another mapper may
|claim the task, which can cause conflicts with any work you have in
|progress (for example in JOSM).
|
|Task: ${taskId}${challengeLine}
|
|To keep the task locked, visit it in MapRoulette before it expires:
|${taskLink}
|${this.notificationFooter}""".stripMargin

val email =
Email(emailSubject, config.getEmailFrom.get, Seq(toAddress), bodyText = Some(emailBody))
mailerClient.send(email)
}

private def notificationFooter: String = {
val urlPrefix = config.getPublicOrigin.get
s"""
Expand Down
10 changes: 10 additions & 0 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,16 @@ maproulette {
startTime = "20:00:00" # 8pm local server time
interval = "24 hours" # once daily
}

# Emails users whose held task locks are about to auto-expire (i.e. be
# cleaned up by cleanLocks). Opt-in: omit `interval` to leave the job
# unscheduled. When scheduled, users must also individually opt in via
# the `task_unlock_warning` subscription on their notification
# preferences (off by default).
taskLockExpiryReminder {
# interval = "1 minute"
leadTime = "10 minutes"
}
}
userMetricsSnapshot {
interval = "24 hours"
Expand Down
20 changes: 20 additions & 0 deletions conf/evolutions/default/119.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# --- MapRoulette Scheme

# --- !Ups

-- Track whether an unlock warning email has been sent for a given lock so
-- the sendTaskLockExpiryReminders job does not email the same user about
-- the same lock more than once.
ALTER TABLE IF EXISTS locked
ADD COLUMN reminder_sent_at timestamp without time zone DEFAULT NULL;;

@ljdelight ljdelight Jul 1, 2026

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.

Why would you remove the timezone? That will break things, the zone should always be tracked so that timings can be correct based on a user's zone (vs the server's configured timezone, which is not reliable).

edit: i know it's a draft, giving you a heads up on that one. also you'd wanna do a 'add column if not exists'.


-- New per-user subscription controlling task-lock-expiry reminder emails.
-- Defaults to NOTIFICATION_EMAIL_NONE (1) so the reminder is opt-in.
ALTER TABLE IF EXISTS user_notification_subscriptions
ADD COLUMN task_unlock_warning integer NOT NULL DEFAULT 1;;


# --- !Downs

ALTER TABLE IF EXISTS user_notification_subscriptions DROP COLUMN task_unlock_warning;;
ALTER TABLE IF EXISTS locked DROP COLUMN reminder_sent_at;;
Loading