From 168fc0d068f2b03365d32d9fc3c71c292b061c9b Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Mon, 25 May 2026 16:01:34 -0300 Subject: [PATCH 01/10] fix priority rules and bounds updates and add indicator boolean --- .../controllers/api/ChallengeController.scala | 9 +- .../maproulette/models/dal/ChallengeDAL.scala | 175 ++++++++++++++---- 2 files changed, 143 insertions(+), 41 deletions(-) diff --git a/app/org/maproulette/controllers/api/ChallengeController.scala b/app/org/maproulette/controllers/api/ChallengeController.scala index de116f88e..5f2f314c0 100644 --- a/app/org/maproulette/controllers/api/ChallengeController.scala +++ b/app/org/maproulette/controllers/api/ChallengeController.scala @@ -1227,8 +1227,13 @@ class ChallengeController @Inject() ( * @return A Json representation of the object */ override def inject(obj: Challenge)(implicit request: Request[Any]): JsValue = { - val tags = this.tagService.listByChallenge(obj.id) - Utils.insertIntoJson(Json.toJson(obj), Tag.TABLE, Json.toJson(tags.map(_.name))) + val tags = this.tagService.listByChallenge(obj.id) + val withTags = Utils.insertIntoJson(Json.toJson(obj), Tag.TABLE, Json.toJson(tags.map(_.name))) + Utils.insertIntoJson( + withTags, + "isRecomputingPriorities", + this.dal.isRecomputingPriorities(obj.id) + ) } /** diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index 9c05cf302..7a7993d09 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -29,7 +29,7 @@ import org.maproulette.session.SearchParameters import org.maproulette.utils.Utils import play.api.db.Database import play.api.libs.json.JodaReads._ -import play.api.libs.json.{JsString, JsValue, Json} +import play.api.libs.json.{JsArray, JsString, JsValue, Json} import scala.collection.mutable.ListBuffer import scala.concurrent.Future @@ -56,6 +56,13 @@ class ChallengeDAL @Inject() ( import scala.concurrent.ExecutionContext.Implicits.global + // Challenge ids whose priorities are currently being recomputed in the background. + // Exposed via isRecomputingPriorities so the UI can show a progress indicator. + private val recomputingPriorities = + java.util.concurrent.ConcurrentHashMap.newKeySet[java.lang.Long]() + + def isRecomputingPriorities(id: Long): Boolean = recomputingPriorities.contains(id) + // The manager for the challenge cache override val cacheManager = new CacheManager[Long, Challenge](config, Config.CACHE_ID_CHALLENGES) // The name of the challenge table @@ -893,10 +900,19 @@ class ChallengeDAL @Inject() ( } } } - // update the task priorities in the background if (updatedPriorityRules) { + recomputingPriorities.add(id) Future { - updateTaskPriorities(user, overrideValidation = true) + try updateTaskPriorities(user, overrideValidation = true) + catch { + case t: Throwable => + logger.error( + s"updateTaskPriorities failed for challenge $id: ${t.getClass.getName}: ${t.getMessage}", + t + ) + } finally { + recomputingPriorities.remove(id) + } } } @@ -937,49 +953,130 @@ class ChallengeDAL @Inject() ( s"Could not update priorties for tasks, no challenge with id $id found." ) } - // make sure that at least one of the challenges is valid - if (overrideValidation || Challenge.isValidRule(challenge.priority.highPriorityRule) || + val hasRules = + Challenge.isValidRule(challenge.priority.highPriorityRule) || Challenge.isValidRule(challenge.priority.mediumPriorityRule) || - Challenge.isValidRule(challenge.priority.lowPriorityRule) || - Challenge.isValidBounds(challenge.priority.highPriorityBounds) || + Challenge.isValidRule(challenge.priority.lowPriorityRule) + val hasBounds = + Challenge.isValidBounds(challenge.priority.highPriorityBounds) || Challenge.isValidBounds(challenge.priority.mediumPriorityBounds) || - Challenge.isValidBounds(challenge.priority.lowPriorityBounds)) { - var pointer = 0 - var currentTasks: List[Task] = List.empty - do { - currentTasks = - listChildren(DEFAULT_NUM_CHILDREN_LIST, pointer * DEFAULT_NUM_CHILDREN_LIST) - - // Let the task model determine the priorities based on both rules and bounds - val highPriorityTasks = - currentTasks.filter(_.getTaskPriority(challenge) == Challenge.PRIORITY_HIGH) - val mediumPriorityTasks = - currentTasks.filter(_.getTaskPriority(challenge) == Challenge.PRIORITY_MEDIUM) - val lowPriorityTasks = - currentTasks.filter(_.getTaskPriority(challenge) == Challenge.PRIORITY_LOW) - - if (highPriorityTasks.nonEmpty) { - val highPriorityIds = highPriorityTasks.map(_.id).mkString(",") - SQL"""UPDATE tasks SET priority = ${Challenge.PRIORITY_HIGH} WHERE id IN (#$highPriorityIds)""" - .executeUpdate() - } - if (mediumPriorityTasks.nonEmpty) { - val mediumPriorityIds = mediumPriorityTasks.map(_.id).mkString(",") - SQL"""UPDATE tasks SET priority = ${Challenge.PRIORITY_MEDIUM} WHERE id IN (#$mediumPriorityIds)""" - .executeUpdate() - } - if (lowPriorityTasks.nonEmpty) { - val lowPriorityIds = lowPriorityTasks.map(_.id).mkString(",") - SQL"""UPDATE tasks SET priority = ${Challenge.PRIORITY_LOW} WHERE id IN (#$lowPriorityIds)""" - .executeUpdate() - } - pointer += 1 - } while (currentTasks.size >= DEFAULT_NUM_CHILDREN_LIST) + Challenge.isValidBounds(challenge.priority.lowPriorityBounds) + + // make sure that at least one of the challenges is valid + if (overrideValidation || hasRules || hasBounds) { + if (hasRules) { + recomputePrioritiesWithRules(challenge) + } else { + recomputePrioritiesFromBoundsOnly(challenge) + } this.taskDAL.clearCaches } } } + /** + * Rule-aware recompute. Walks every task, parses its geometries, evaluates + * each rule and bounds check in Scala. Slow (per-task `update_geometry` call + * inside the parser dominates on large challenges) but required when any + * priority rule needs OSM-tag inspection. + */ + private def recomputePrioritiesWithRules( + challenge: Challenge + )(implicit id: Long, c: Connection): Unit = { + var pointer = 0 + var currentTasks: List[Task] = List.empty + do { + // listChildren's second arg is a page number, not an offset. + currentTasks = listChildren(DEFAULT_NUM_CHILDREN_LIST, pointer) + + val byPriority = currentTasks.groupBy { task => + try task.getTaskPriority(challenge) + catch { + case t: Throwable => + logger.warn( + s"Could not evaluate priority for task ${task.id} in challenge $id (${t.getClass.getSimpleName}: ${t.getMessage}); falling back to defaultPriority=${challenge.priority.defaultPriority}" + ) + challenge.priority.defaultPriority + } + } + + byPriority.foreach { + case (priority, tasks) => + val ids = tasks.map(_.id).mkString(",") + SQL"""UPDATE tasks SET priority = $priority WHERE id IN (#$ids)""".executeUpdate() + } + pointer += 1 + } while (currentTasks.size >= DEFAULT_NUM_CHILDREN_LIST) + } + + /** + * Bounds-only fast path: when no priority *rule* (OSM-tag predicate) is set + * we don't need any task JSON at all. The indexed `tasks.location` centroid + * is sufficient, so we push the entire recompute down to a handful of + * PostGIS UPDATEs and skip the per-task `update_geometry` round-trip that + * makes the Scala loop run for minutes on large challenges. + * + * Precedence is preserved by writing in reverse: default → LOW → MEDIUM → + * HIGH, so each higher level overwrites lower-level matches for tasks that + * fall inside multiple bounds (matching `getTaskPriority`'s short-circuit + * order, which checks HIGH first). + * + * Tasks with a NULL `location` (geometry never materialized) silently keep + * `defaultPriority` here — these tasks would also not contribute meaningful + * coordinates to the Scala vertex-in-polygon check, so the outcome is the + * same in practice. + */ + private def recomputePrioritiesFromBoundsOnly( + challenge: Challenge + )(implicit id: Long, c: Connection): Unit = { + val defaultPriority = challenge.priority.defaultPriority + SQL"""UPDATE tasks SET priority = $defaultPriority WHERE parent_id = $id""".executeUpdate() + + Seq( + (challenge.priority.lowPriorityBounds, Challenge.PRIORITY_LOW), + (challenge.priority.mediumPriorityBounds, Challenge.PRIORITY_MEDIUM), + (challenge.priority.highPriorityBounds, Challenge.PRIORITY_HIGH) + ).foreach { + case (boundsOpt, priority) => + boundsOpt.foreach { bounds => + extractBoundsGeometries(bounds).foreach { geomJson => + try { + SQL"""UPDATE tasks SET priority = $priority + WHERE parent_id = $id + AND location IS NOT NULL + AND ST_Intersects(location, ST_GeomFromGeoJSON($geomJson))""".executeUpdate() + } catch { + case t: Throwable => + logger.warn( + s"Skipping priority=$priority bounds for challenge $id (${t.getClass.getSimpleName}: ${t.getMessage})" + ) + } + } + } + } + } + + /** + * Parse a priority-bounds string (either a JSON array of GeoJSON Features or + * a single Feature/geometry) and return one GeoJSON-geometry string per + * feature, suitable for `ST_GeomFromGeoJSON`. Returns Nil on parse error. + */ + private def extractBoundsGeometries(boundsJson: String): List[String] = { + try { + val parsed = Json.parse(boundsJson) + val features = parsed match { + case arr: JsArray => arr.value.toList + case obj => List(obj) + } + features.flatMap { feat => + val geom = (feat \ "geometry").asOpt[JsValue].getOrElse(feat) + if ((geom \ "type").asOpt[String].isDefined) Some(Json.stringify(geom)) else None + } + } catch { + case _: Throwable => Nil + } + } + /** * Lists the children of the parent, override the base functionality and includes the geojson * as part of the query so that it doesn't have to fetch it each and every time. From 388782f18c4113c2172b27efa53276e3ba1c9fd7 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Mon, 25 May 2026 16:55:36 -0300 Subject: [PATCH 02/10] Refactor task priority recomputation to use a single SQL UPDATE statement, improving performance and safety by utilizing parameter binding for user-supplied values. Removed outdated methods for priority calculation based on rules and bounds. --- .../maproulette/models/dal/ChallengeDAL.scala | 234 ++++++++++++------ 1 file changed, 159 insertions(+), 75 deletions(-) diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index 7a7993d09..adbb793e8 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -964,96 +964,180 @@ class ChallengeDAL @Inject() ( // make sure that at least one of the challenges is valid if (overrideValidation || hasRules || hasBounds) { - if (hasRules) { - recomputePrioritiesWithRules(challenge) - } else { - recomputePrioritiesFromBoundsOnly(challenge) - } + recomputePriorities(challenge) this.taskDAL.clearCaches } } } /** - * Rule-aware recompute. Walks every task, parses its geometries, evaluates - * each rule and bounds check in Scala. Slow (per-task `update_geometry` call - * inside the parser dominates on large challenges) but required when any - * priority rule needs OSM-tag inspection. + * Recomputes every task priority for a challenge in a single SQL UPDATE. + * Bounds become `ST_Intersects(location, ST_GeomFromGeoJSON(?))` (indexed); + * rules become `EXISTS` subqueries over `tasks.geojson` jsonb properties. + * CASE evaluates top-down so HIGH wins over MEDIUM wins over LOW, matching + * `getTaskPriority`'s short-circuit precedence. Throws if a rule uses a + * construct the translator doesn't handle — the outer Future catches it. + * + * Every user-supplied string (GeoJSON bounds, rule keys, rule values) is + * passed through anorm named parameters rather than inlined into the SQL + * text. Inlining is unsafe even with quote-escaping because the Postgres + * JDBC driver pre-parses the statement string for ODBC-style `{...}` + * escape syntax — and bounds GeoJSON is full of `{` and `}` braces, which + * blew up `prepareStatement` before the query ever reached the server. */ - private def recomputePrioritiesWithRules( + private def recomputePriorities( challenge: Challenge )(implicit id: Long, c: Connection): Unit = { - var pointer = 0 - var currentTasks: List[Task] = List.empty - do { - // listChildren's second arg is a page number, not an offset. - currentTasks = listChildren(DEFAULT_NUM_CHILDREN_LIST, pointer) - - val byPriority = currentTasks.groupBy { task => - try task.getTaskPriority(challenge) - catch { - case t: Throwable => - logger.warn( - s"Could not evaluate priority for task ${task.id} in challenge $id (${t.getClass.getSimpleName}: ${t.getMessage}); falling back to defaultPriority=${challenge.priority.defaultPriority}" - ) - challenge.priority.defaultPriority - } + val default = challenge.priority.defaultPriority + val params = scala.collection.mutable.ListBuffer.empty[NamedParameter] + + def bind(value: String): String = { + val name = s"p${params.size}" + params += NamedParameter(name, value) + s"{$name}" + } + + def boundsSql(boundsJson: String): Option[String] = { + val geoms = extractBoundsGeometries(boundsJson) + if (geoms.isEmpty) None + else + Some( + geoms + .map(g => s"ST_Intersects(location, ST_GeomFromGeoJSON(${bind(g)}))") + .mkString("(location IS NOT NULL AND (", " OR ", "))") + ) + } + + def ruleSql(ruleJson: JsValue): Option[String] = { + val joiner = + if ((ruleJson \ "condition").asOpt[String].exists(_.equalsIgnoreCase("OR"))) " OR " + else " AND " + val rules = (ruleJson \ "rules").asOpt[List[JsValue]].getOrElse(Nil) + val translated = rules.map { r => + if ((r \ "rules").asOpt[JsValue].isDefined) ruleSql(r) else singleRuleSql(r) } + if (rules.isEmpty || translated.exists(_.isEmpty)) None + else { + val parts = translated.flatten + Some(if (parts.size == 1) parts.head else parts.mkString("(", joiner, ")")) + } + } - byPriority.foreach { - case (priority, tasks) => - val ids = tasks.map(_.id).mkString(",") - SQL"""UPDATE tasks SET priority = $priority WHERE id IN (#$ids)""".executeUpdate() + def singleRuleSql(rule: JsValue): Option[String] = + try { + val valueRaw = (rule \ "value").as[String] + val valueType = (rule \ "type").as[String] + val operator = (rule \ "operator").as[String] + if (valueType == "bounds") boundsRuleSql(valueRaw, operator) + else propertyRuleSql(valueRaw, valueType, operator) + } catch { case _: Throwable => None } + + def boundsRuleSql(valueRaw: String, operator: String): Option[String] = { + val bbox = valueRaw.split(",").map(_.trim.toDouble) + if (bbox.length != 4) None + else { + // bbox values are validated as Doubles; safe to inline. + val env = s"ST_MakeEnvelope(${bbox(0)}, ${bbox(1)}, ${bbox(2)}, ${bbox(3)}, 4326)" + operator match { + case "contains" => Some(s"(location IS NOT NULL AND location && $env)") + case "not_contains" => Some(s"(location IS NOT NULL AND NOT (location && $env))") + case _ => None + } } - pointer += 1 - } while (currentTasks.size >= DEFAULT_NUM_CHILDREN_LIST) - } + } - /** - * Bounds-only fast path: when no priority *rule* (OSM-tag predicate) is set - * we don't need any task JSON at all. The indexed `tasks.location` centroid - * is sufficient, so we push the entire recompute down to a handful of - * PostGIS UPDATEs and skip the per-task `update_geometry` round-trip that - * makes the Scala loop run for minutes on large challenges. - * - * Precedence is preserved by writing in reverse: default → LOW → MEDIUM → - * HIGH, so each higher level overwrites lower-level matches for tasks that - * fall inside multiple bounds (matching `getTaskPriority`'s short-circuit - * order, which checks HIGH first). - * - * Tasks with a NULL `location` (geometry never materialized) silently keep - * `defaultPriority` here — these tasks would also not contribute meaningful - * coordinates to the Scala vertex-in-polygon check, so the outcome is the - * same in practice. - */ - private def recomputePrioritiesFromBoundsOnly( - challenge: Challenge - )(implicit id: Long, c: Connection): Unit = { - val defaultPriority = challenge.priority.defaultPriority - SQL"""UPDATE tasks SET priority = $defaultPriority WHERE parent_id = $id""".executeUpdate() - - Seq( - (challenge.priority.lowPriorityBounds, Challenge.PRIORITY_LOW), - (challenge.priority.mediumPriorityBounds, Challenge.PRIORITY_MEDIUM), - (challenge.priority.highPriorityBounds, Challenge.PRIORITY_HIGH) - ).foreach { - case (boundsOpt, priority) => - boundsOpt.foreach { bounds => - extractBoundsGeometries(bounds).foreach { geomJson => - try { - SQL"""UPDATE tasks SET priority = $priority - WHERE parent_id = $id - AND location IS NOT NULL - AND ST_Intersects(location, ST_GeomFromGeoJSON($geomJson))""".executeUpdate() - } catch { - case t: Throwable => - logger.warn( - s"Skipping priority=$priority bounds for challenge $id (${t.getClass.getSimpleName}: ${t.getMessage})" - ) - } - } + def propertyRuleSql( + valueRaw: String, + valueType: String, + operator: String + ): Option[String] = valueRaw.split("\\.", 2) match { + case Array(rawKey, rawValue) => + val keyParam = bind(rawKey) + val valueParam = bind(rawValue) + val check: Option[String] = (valueType, operator) match { + case ("string", "equal") => Some(s"p.v = $valueParam") + case ("string", "not_equal") => Some(s"p.v <> $valueParam") + case ("string", "contains") => Some(s"position($valueParam IN p.v) > 0") + case ("string", "not_contains") => Some(s"position($valueParam IN p.v) = 0") + case ("string", "is_empty") => Some("(p.v IS NULL OR p.v = '')") + case ("string", "is_not_empty") => Some("(p.v IS NOT NULL AND p.v <> '')") + case ("double", op) => + for { + sqlOp <- numericOp(op) + n <- scala.util.Try(rawValue.toDouble).toOption + } yield s"(p.v ~ '^-?[0-9]+\\.?[0-9]*$$' AND p.v::double precision $sqlOp $n)" + case ("integer" | "long", op) => + for { + sqlOp <- numericOp(op) + n <- scala.util.Try(rawValue.toLong).toOption + } yield s"(p.v ~ '^-?[0-9]+$$' AND p.v::bigint $sqlOp $n)" + case _ => None + } + // jsonb_build_array()/_object() instead of '[]'::jsonb / '{}'::jsonb — + // see method-level comment for why braces are toxic in the statement. + check.map { c => + s"(geojson IS NOT NULL AND EXISTS (" + + "SELECT 1 FROM jsonb_array_elements(COALESCE(geojson -> 'features', jsonb_build_array())) AS f, " + + "jsonb_each_text(COALESCE(f -> 'properties', jsonb_build_object())) AS p(k, v) " + + s"WHERE LOWER(p.k) = LOWER($keyParam) AND $c))" } + case _ => None } + + def levelWhen( + boundsOpt: Option[String], + ruleOpt: Option[String], + priority: Int + ): Option[String] = { + val parts = List( + boundsOpt.flatMap(boundsSql), + ruleOpt.filter(r => Challenge.isValidRule(Some(r))).map { r => + ruleSql(Json.parse(r)).getOrElse( + throw new IllegalArgumentException( + s"Priority rule can't be translated to SQL — please simplify: $r" + ) + ) + } + ).flatten + if (parts.isEmpty) None else Some(s"WHEN ${parts.mkString(" OR ")} THEN $priority") + } + + val whens = List( + levelWhen( + challenge.priority.highPriorityBounds, + challenge.priority.highPriorityRule, + Challenge.PRIORITY_HIGH + ), + levelWhen( + challenge.priority.mediumPriorityBounds, + challenge.priority.mediumPriorityRule, + Challenge.PRIORITY_MEDIUM + ), + levelWhen( + challenge.priority.lowPriorityBounds, + challenge.priority.lowPriorityRule, + Challenge.PRIORITY_LOW + ) + ).flatten + + val expr = + if (whens.isEmpty) default.toString + else s"CASE ${whens.mkString(" ")} ELSE $default END" + + params += NamedParameter("pid", id) + SQL(s"UPDATE tasks SET priority = $expr WHERE parent_id = {pid}") + .on(params.toSeq: _*) + .executeUpdate() + } + + private def numericOp(op: String): Option[String] = op match { + case "==" => Some("=") + case "!=" => Some("<>") + case "<" => Some("<") + case "<=" => Some("<=") + case ">" => Some(">") + case ">=" => Some(">=") + case _ => None } /** From 2c75e7a6d8b09bd2f624ef1a294cca7b48187a53 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Mon, 25 May 2026 17:23:45 -0300 Subject: [PATCH 03/10] Enhance challenge priority recomputation by introducing error handling and state management. Added support for tracking recompute errors and improved UI feedback during priority updates. --- .../controllers/api/ChallengeController.scala | 6 +- .../maproulette/models/dal/ChallengeDAL.scala | 117 +++++++++++------- 2 files changed, 75 insertions(+), 48 deletions(-) diff --git a/app/org/maproulette/controllers/api/ChallengeController.scala b/app/org/maproulette/controllers/api/ChallengeController.scala index 5f2f314c0..32a29dc29 100644 --- a/app/org/maproulette/controllers/api/ChallengeController.scala +++ b/app/org/maproulette/controllers/api/ChallengeController.scala @@ -1229,11 +1229,15 @@ class ChallengeController @Inject() ( override def inject(obj: Challenge)(implicit request: Request[Any]): JsValue = { val tags = this.tagService.listByChallenge(obj.id) val withTags = Utils.insertIntoJson(Json.toJson(obj), Tag.TABLE, Json.toJson(tags.map(_.name))) - Utils.insertIntoJson( + val withFlag = Utils.insertIntoJson( withTags, "isRecomputingPriorities", this.dal.isRecomputingPriorities(obj.id) ) + this.dal.priorityRecomputeError(obj.id) match { + case Some(err) => Utils.insertIntoJson(withFlag, "priorityRecomputeError", err) + case None => withFlag + } } /** diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index adbb793e8..6ba42a3d0 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -56,12 +56,48 @@ class ChallengeDAL @Inject() ( import scala.concurrent.ExecutionContext.Implicits.global - // Challenge ids whose priorities are currently being recomputed in the background. - // Exposed via isRecomputingPriorities so the UI can show a progress indicator. - private val recomputingPriorities = - java.util.concurrent.ConcurrentHashMap.newKeySet[java.lang.Long]() + // Process-local bookkeeping for the UI's recompute indicator. The visibility + // window keeps fast recomputes on screen long enough for the frontend's 3s + // poll to catch them; the inFlight counter keeps it on across overlapping + // saves. + private case class RecomputeState( + inFlight: java.util.concurrent.atomic.AtomicInteger, + visibleUntil: java.util.concurrent.atomic.AtomicLong, + errorMessage: java.util.concurrent.atomic.AtomicReference[String] + ) + private val recomputeStates = scala.collection.concurrent.TrieMap.empty[Long, RecomputeState] + private val MIN_INDICATOR_MS = 3000L + + private def recomputeStateFor(id: Long): RecomputeState = + recomputeStates.getOrElseUpdate( + id, + RecomputeState( + new java.util.concurrent.atomic.AtomicInteger(0), + new java.util.concurrent.atomic.AtomicLong(0L), + new java.util.concurrent.atomic.AtomicReference[String](null) + ) + ) + + def isRecomputingPriorities(id: Long): Boolean = + recomputeStates.get(id).exists { s => + s.inFlight.get() > 0 || s.visibleUntil.get() > System.currentTimeMillis() + } + + def priorityRecomputeError(id: Long): Option[String] = + recomputeStates.get(id).flatMap(s => Option(s.errorMessage.get())) + + private def beginRecompute(id: Long): Unit = { + val s = recomputeStateFor(id) + s.inFlight.incrementAndGet() + s.errorMessage.set(null) + } - def isRecomputingPriorities(id: Long): Boolean = recomputingPriorities.contains(id) + private def endRecompute(id: Long, failure: Option[Throwable]): Unit = + recomputeStates.get(id).foreach { s => + s.inFlight.decrementAndGet() + s.visibleUntil.set(System.currentTimeMillis() + MIN_INDICATOR_MS) + failure.foreach(t => s.errorMessage.set(s"${t.getClass.getSimpleName}: ${t.getMessage}")) + } // The manager for the challenge cache override val cacheManager = new CacheManager[Long, Challenge](config, Config.CACHE_ID_CHALLENGES) @@ -901,17 +937,19 @@ class ChallengeDAL @Inject() ( } } if (updatedPriorityRules) { - recomputingPriorities.add(id) + beginRecompute(id) Future { + var failure: Option[Throwable] = None try updateTaskPriorities(user, overrideValidation = true) catch { case t: Throwable => + failure = Some(t) logger.error( s"updateTaskPriorities failed for challenge $id: ${t.getClass.getName}: ${t.getMessage}", t ) } finally { - recomputingPriorities.remove(id) + endRecompute(id, failure) } } } @@ -970,21 +1008,9 @@ class ChallengeDAL @Inject() ( } } - /** - * Recomputes every task priority for a challenge in a single SQL UPDATE. - * Bounds become `ST_Intersects(location, ST_GeomFromGeoJSON(?))` (indexed); - * rules become `EXISTS` subqueries over `tasks.geojson` jsonb properties. - * CASE evaluates top-down so HIGH wins over MEDIUM wins over LOW, matching - * `getTaskPriority`'s short-circuit precedence. Throws if a rule uses a - * construct the translator doesn't handle — the outer Future catches it. - * - * Every user-supplied string (GeoJSON bounds, rule keys, rule values) is - * passed through anorm named parameters rather than inlined into the SQL - * text. Inlining is unsafe even with quote-escaping because the Postgres - * JDBC driver pre-parses the statement string for ODBC-style `{...}` - * escape syntax — and bounds GeoJSON is full of `{` and `}` braces, which - * blew up `prepareStatement` before the query ever reached the server. - */ + // User-supplied strings (GeoJSON bounds, rule keys/values) must be bound as + // anorm parameters rather than inlined: the Postgres JDBC driver pre-parses + // statement text for ODBC-style {…} escape syntax, and GeoJSON contains {}. private def recomputePriorities( challenge: Challenge )(implicit id: Long, c: Connection): Unit = { @@ -1003,8 +1029,8 @@ class ChallengeDAL @Inject() ( else Some( geoms - .map(g => s"ST_Intersects(location, ST_GeomFromGeoJSON(${bind(g)}))") - .mkString("(location IS NOT NULL AND (", " OR ", "))") + .map(g => s"ST_Intersects(geom, ST_GeomFromGeoJSON(${bind(g)}))") + .mkString("(geom IS NOT NULL AND (", " OR ", "))") ) } @@ -1030,13 +1056,12 @@ class ChallengeDAL @Inject() ( val operator = (rule \ "operator").as[String] if (valueType == "bounds") boundsRuleSql(valueRaw, operator) else propertyRuleSql(valueRaw, valueType, operator) - } catch { case _: Throwable => None } + } catch { case scala.util.control.NonFatal(_) => None } def boundsRuleSql(valueRaw: String, operator: String): Option[String] = { val bbox = valueRaw.split(",").map(_.trim.toDouble) if (bbox.length != 4) None else { - // bbox values are validated as Doubles; safe to inline. val env = s"ST_MakeEnvelope(${bbox(0)}, ${bbox(1)}, ${bbox(2)}, ${bbox(3)}, 4326)" operator match { case "contains" => Some(s"(location IS NOT NULL AND location && $env)") @@ -1052,8 +1077,10 @@ class ChallengeDAL @Inject() ( operator: String ): Option[String] = valueRaw.split("\\.", 2) match { case Array(rawKey, rawValue) => - val keyParam = bind(rawKey) - val valueParam = bind(rawValue) + val keyParam = bind(rawKey) + val valueParam = bind(rawValue) + val doubleRegex = "^[+-]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][+-]?[0-9]+)?$" + val longRegex = "^[+-]?[0-9]+$" val check: Option[String] = (valueType, operator) match { case ("string", "equal") => Some(s"p.v = $valueParam") case ("string", "not_equal") => Some(s"p.v <> $valueParam") @@ -1065,21 +1092,25 @@ class ChallengeDAL @Inject() ( for { sqlOp <- numericOp(op) n <- scala.util.Try(rawValue.toDouble).toOption - } yield s"(p.v ~ '^-?[0-9]+\\.?[0-9]*$$' AND p.v::double precision $sqlOp $n)" + } yield s"(p.v ~ '$doubleRegex' AND p.v::double precision $sqlOp $n)" case ("integer" | "long", op) => for { sqlOp <- numericOp(op) n <- scala.util.Try(rawValue.toLong).toOption - } yield s"(p.v ~ '^-?[0-9]+$$' AND p.v::bigint $sqlOp $n)" + } yield s"(p.v ~ '$longRegex' AND p.v::bigint $sqlOp $n)" case _ => None } - // jsonb_build_array()/_object() instead of '[]'::jsonb / '{}'::jsonb — - // see method-level comment for why braces are toxic in the statement. + // jsonb_typeof guards keep one malformed task from aborting the UPDATE; + // jsonb_build_*() instead of '[]'/'{}' literals to avoid braces in the + // statement text (see method-level note). check.map { c => - s"(geojson IS NOT NULL AND EXISTS (" + - "SELECT 1 FROM jsonb_array_elements(COALESCE(geojson -> 'features', jsonb_build_array())) AS f, " + - "jsonb_each_text(COALESCE(f -> 'properties', jsonb_build_object())) AS p(k, v) " + - s"WHERE LOWER(p.k) = LOWER($keyParam) AND $c))" + s"(geojson IS NOT NULL AND EXISTS (SELECT 1 FROM jsonb_array_elements(" + + "CASE WHEN jsonb_typeof(geojson -> 'features') = 'array' " + + "THEN geojson -> 'features' ELSE jsonb_build_array() END" + + ") AS f, jsonb_each_text(" + + "CASE WHEN jsonb_typeof(f -> 'properties') = 'object' " + + "THEN f -> 'properties' ELSE jsonb_build_object() END" + + s") AS p(k, v) WHERE LOWER(p.k) = LOWER($keyParam) AND $c))" } case _ => None } @@ -1093,9 +1124,7 @@ class ChallengeDAL @Inject() ( boundsOpt.flatMap(boundsSql), ruleOpt.filter(r => Challenge.isValidRule(Some(r))).map { r => ruleSql(Json.parse(r)).getOrElse( - throw new IllegalArgumentException( - s"Priority rule can't be translated to SQL — please simplify: $r" - ) + throw new IllegalArgumentException(s"Priority rule can't be translated to SQL: $r") ) } ).flatten @@ -1140,15 +1169,9 @@ class ChallengeDAL @Inject() ( case _ => None } - /** - * Parse a priority-bounds string (either a JSON array of GeoJSON Features or - * a single Feature/geometry) and return one GeoJSON-geometry string per - * feature, suitable for `ST_GeomFromGeoJSON`. Returns Nil on parse error. - */ private def extractBoundsGeometries(boundsJson: String): List[String] = { try { - val parsed = Json.parse(boundsJson) - val features = parsed match { + val features = Json.parse(boundsJson) match { case arr: JsArray => arr.value.toList case obj => List(obj) } @@ -1157,7 +1180,7 @@ class ChallengeDAL @Inject() ( if ((geom \ "type").asOpt[String].isDefined) Some(Json.stringify(geom)) else None } } catch { - case _: Throwable => Nil + case scala.util.control.NonFatal(_) => Nil } } From 7acac2412fcb18a0a5d54e51ee7df1427abcc2bc Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Mon, 25 May 2026 17:32:06 -0300 Subject: [PATCH 04/10] remove priorityRecomputeError unused code --- .../controllers/api/ChallengeController.scala | 6 +---- .../maproulette/models/dal/ChallengeDAL.scala | 23 +++++-------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/app/org/maproulette/controllers/api/ChallengeController.scala b/app/org/maproulette/controllers/api/ChallengeController.scala index 32a29dc29..5f2f314c0 100644 --- a/app/org/maproulette/controllers/api/ChallengeController.scala +++ b/app/org/maproulette/controllers/api/ChallengeController.scala @@ -1229,15 +1229,11 @@ class ChallengeController @Inject() ( override def inject(obj: Challenge)(implicit request: Request[Any]): JsValue = { val tags = this.tagService.listByChallenge(obj.id) val withTags = Utils.insertIntoJson(Json.toJson(obj), Tag.TABLE, Json.toJson(tags.map(_.name))) - val withFlag = Utils.insertIntoJson( + Utils.insertIntoJson( withTags, "isRecomputingPriorities", this.dal.isRecomputingPriorities(obj.id) ) - this.dal.priorityRecomputeError(obj.id) match { - case Some(err) => Utils.insertIntoJson(withFlag, "priorityRecomputeError", err) - case None => withFlag - } } /** diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index 6ba42a3d0..4fa3854a0 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -62,8 +62,7 @@ class ChallengeDAL @Inject() ( // saves. private case class RecomputeState( inFlight: java.util.concurrent.atomic.AtomicInteger, - visibleUntil: java.util.concurrent.atomic.AtomicLong, - errorMessage: java.util.concurrent.atomic.AtomicReference[String] + visibleUntil: java.util.concurrent.atomic.AtomicLong ) private val recomputeStates = scala.collection.concurrent.TrieMap.empty[Long, RecomputeState] private val MIN_INDICATOR_MS = 3000L @@ -73,8 +72,7 @@ class ChallengeDAL @Inject() ( id, RecomputeState( new java.util.concurrent.atomic.AtomicInteger(0), - new java.util.concurrent.atomic.AtomicLong(0L), - new java.util.concurrent.atomic.AtomicReference[String](null) + new java.util.concurrent.atomic.AtomicLong(0L) ) ) @@ -83,20 +81,13 @@ class ChallengeDAL @Inject() ( s.inFlight.get() > 0 || s.visibleUntil.get() > System.currentTimeMillis() } - def priorityRecomputeError(id: Long): Option[String] = - recomputeStates.get(id).flatMap(s => Option(s.errorMessage.get())) + private def beginRecompute(id: Long): Unit = + recomputeStateFor(id).inFlight.incrementAndGet() - private def beginRecompute(id: Long): Unit = { - val s = recomputeStateFor(id) - s.inFlight.incrementAndGet() - s.errorMessage.set(null) - } - - private def endRecompute(id: Long, failure: Option[Throwable]): Unit = + private def endRecompute(id: Long): Unit = recomputeStates.get(id).foreach { s => s.inFlight.decrementAndGet() s.visibleUntil.set(System.currentTimeMillis() + MIN_INDICATOR_MS) - failure.foreach(t => s.errorMessage.set(s"${t.getClass.getSimpleName}: ${t.getMessage}")) } // The manager for the challenge cache @@ -939,17 +930,15 @@ class ChallengeDAL @Inject() ( if (updatedPriorityRules) { beginRecompute(id) Future { - var failure: Option[Throwable] = None try updateTaskPriorities(user, overrideValidation = true) catch { case t: Throwable => - failure = Some(t) logger.error( s"updateTaskPriorities failed for challenge $id: ${t.getClass.getName}: ${t.getMessage}", t ) } finally { - endRecompute(id, failure) + endRecompute(id) } } } From 24e2c0e09bac4bfeed4f6229c1bf7ebebb1f7c77 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Wed, 3 Jun 2026 13:44:08 -0300 Subject: [PATCH 05/10] Merge branch 'main' into fix-priority-rules-and-bounds-updates --- app/org/maproulette/Config.scala | 15 +- .../controllers/api/ChallengeController.scala | 380 +++++++++++ .../controllers/api/TaskController.scala | 201 ++++-- .../maproulette/exception/StatusMessage.scala | 3 + .../controller/CommentController.scala | 36 + .../controller/ProjectController.scala | 21 + .../controller/SearchController.scala | 88 +++ .../framework/controller/TaskController.scala | 197 +++++- .../framework/controller/UserController.scala | 32 +- .../framework/graphql/schemas/MRSchema.scala | 4 + .../graphql/schemas/MRSchemaTypes.scala | 24 + .../graphql/schemas/ProjectSchema.scala | 2 +- .../framework/mixins/TaskJSONMixin.scala | 4 +- .../framework/mixins/TaskParserMixin.scala | 53 +- .../framework/model/Challenge.scala | 88 ++- .../framework/model/ChallengeLike.scala | 18 + .../framework/model/ChallengeTaskMarker.scala | 48 ++ .../framework/model/ClusteredPoint.scala | 4 +- .../framework/model/CompletionMetrics.scala | 36 + .../maproulette/framework/model/Project.scala | 5 +- .../maproulette/framework/model/Task.scala | 152 ++++- .../framework/model/TaskCluster.scala | 4 +- .../framework/model/TaskClusterSummary.scala | 35 + .../framework/model/TaskMarker.scala | 42 ++ .../framework/model/TaskMarkerResponse.scala | 47 ++ .../maproulette/framework/model/User.scala | 35 +- .../ChallengeCommentRepository.scala | 54 ++ .../repository/ChallengeRepository.scala | 4 +- .../repository/CommentRepository.scala | 55 ++ .../repository/ProjectRepository.scala | 16 +- .../repository/TaskBundleRepository.scala | 2 +- .../repository/TaskClusterRepository.scala | 518 ++++++++++++++- .../framework/repository/TaskRepository.scala | 101 ++- .../repository/TaskReviewRepository.scala | 4 +- .../repository/TileAggregateRepository.scala | 384 +++++++++++ .../framework/repository/UserRepository.scala | 14 +- .../UserSavedObjectsRepository.scala | 95 +++ .../framework/service/CommentService.scala | 44 ++ .../framework/service/ProjectService.scala | 64 ++ .../framework/service/ServiceManager.scala | 5 +- .../service/TaskClusterService.scala | 135 +++- .../service/TileAggregateService.scala | 92 +++ .../framework/service/UserService.scala | 76 ++- app/org/maproulette/jobs/Scheduler.scala | 7 + app/org/maproulette/jobs/SchedulerActor.scala | 42 +- .../maproulette/models/dal/ChallengeDAL.scala | 621 +++++++++++++++--- app/org/maproulette/models/dal/TaskDAL.scala | 158 +++-- .../models/dal/VirtualChallengeDAL.scala | 4 +- .../models/utils/ChallengeFormatters.scala | 92 ++- .../provider/ChallengeProvider.scala | 106 ++- .../provider/KeepRightProvider.scala | 24 +- build.sbt | 3 +- conf/application.conf | 7 + conf/evolutions/default/107.sql | 455 +++++++++++++ conf/evolutions/default/108.sql | 8 + conf/evolutions/default/109.sql | 8 + conf/evolutions/default/110.sql | 23 + conf/evolutions/default/111.sql | 283 ++++++++ conf/evolutions/default/112.sql | 26 + conf/evolutions/default/113.sql | 13 + conf/evolutions/default/114.sql | 25 + conf/evolutions/default/115.sql | 70 ++ conf/evolutions/default/116.sql | 25 + conf/swagger-custom-mappings.yml | 19 +- conf/test.conf | 38 ++ conf/v2_route/bundle.api | 5 + conf/v2_route/challenge.api | 407 +++++++++++- conf/v2_route/changes.api | 3 + conf/v2_route/comment.api | 62 ++ conf/v2_route/data.api | 97 ++- conf/v2_route/follow.api | 6 + conf/v2_route/keyword.api | 10 + conf/v2_route/leaderboard.api | 6 + conf/v2_route/notification.api | 7 + conf/v2_route/project.api | 43 ++ conf/v2_route/review.api | 12 + conf/v2_route/search.api | 18 + conf/v2_route/service.api | 1 + conf/v2_route/snapshot.api | 5 + conf/v2_route/task.api | 308 +++++++++ conf/v2_route/team.api | 16 + conf/v2_route/user.api | 61 ++ conf/v2_route/virtualchallenge.api | 14 + conf/v2_route/virtualproject.api | 2 + project/plugins.sbt | 12 +- .../repository/TaskRepositorySpec.scala | 8 +- .../TileAggregateRepositorySpec.scala | 65 ++ .../framework/util/FrameworkHelper.scala | 9 +- .../maproulette/models/ChallengeSpec.scala | 5 +- test/org/maproulette/utils/TestSpec.scala | 9 +- 90 files changed, 6099 insertions(+), 386 deletions(-) create mode 100644 app/org/maproulette/framework/controller/SearchController.scala create mode 100644 app/org/maproulette/framework/model/ChallengeLike.scala create mode 100644 app/org/maproulette/framework/model/ChallengeTaskMarker.scala create mode 100644 app/org/maproulette/framework/model/CompletionMetrics.scala create mode 100644 app/org/maproulette/framework/model/TaskClusterSummary.scala create mode 100644 app/org/maproulette/framework/model/TaskMarker.scala create mode 100644 app/org/maproulette/framework/model/TaskMarkerResponse.scala create mode 100644 app/org/maproulette/framework/repository/TileAggregateRepository.scala create mode 100644 app/org/maproulette/framework/service/TileAggregateService.scala create mode 100644 conf/evolutions/default/107.sql create mode 100644 conf/evolutions/default/108.sql create mode 100644 conf/evolutions/default/109.sql create mode 100644 conf/evolutions/default/110.sql create mode 100644 conf/evolutions/default/111.sql create mode 100644 conf/evolutions/default/112.sql create mode 100644 conf/evolutions/default/113.sql create mode 100644 conf/evolutions/default/114.sql create mode 100644 conf/evolutions/default/115.sql create mode 100644 conf/evolutions/default/116.sql create mode 100644 conf/test.conf create mode 100644 conf/v2_route/search.api create mode 100644 test/org/maproulette/framework/repository/TileAggregateRepositorySpec.scala diff --git a/app/org/maproulette/Config.scala b/app/org/maproulette/Config.scala index 47fb8d769..1bf1f49ba 100644 --- a/app/org/maproulette/Config.scala +++ b/app/org/maproulette/Config.scala @@ -338,6 +338,10 @@ object Config { s"$SUB_GROUP_SCHEDULER.archiveChallenges.staleTimeInMonths" val KEY_SCHEDULER_UPDATE_CHALLENGE_COMPLETION_INTERVAL = s"$SUB_GROUP_SCHEDULER.updateChallengeCompletionMetrics.interval" + // Drains the dirty-cell queue and keeps the pre-computed tile pyramid fresh. + // Set to an empty string to disable (e.g. integration tests). + val KEY_SCHEDULER_REBUILD_DIRTY_TILE_CELLS_INTERVAL = + s"$SUB_GROUP_SCHEDULER.rebuildDirtyTileCells.interval" val KEY_SCHEDULER_NOTIFICATION_DIGEST_EMAIL_INTERVAL = s"$SUB_GROUP_SCHEDULER.notifications.digestEmail.interval" val KEY_SCHEDULER_NOTIFICATION_DIGEST_EMAIL_START = @@ -348,12 +352,11 @@ object Config { val KEY_SCHEDULER_SNAPSHOT_CHALLENGES_INTERVAL = s"$SUB_GROUP_SCHEDULER.challengesSnapshot.interval" val KEY_SCHEDULER_SNAPSHOT_CHALLENGES_START = s"$SUB_GROUP_SCHEDULER.challengesSnapshot.startTime" - - val KEY_MAPROULETTE_FRONTEND = s"$GROUP_MAPROULETTE.frontend" - val SUB_GROUP_MAPILLARY = s"$GROUP_MAPROULETTE.mapillary" - val KEY_MAPILLARY_HOST = s"$SUB_GROUP_MAPILLARY.host" - val KEY_MAPILLARY_CLIENT_ID = s"$SUB_GROUP_MAPILLARY.clientId" - val KEY_MAPILLARY_BORDER = s"$SUB_GROUP_MAPILLARY.border" + val KEY_MAPROULETTE_FRONTEND = s"$GROUP_MAPROULETTE.frontend" + val SUB_GROUP_MAPILLARY = s"$GROUP_MAPROULETTE.mapillary" + val KEY_MAPILLARY_HOST = s"$SUB_GROUP_MAPILLARY.host" + val KEY_MAPILLARY_CLIENT_ID = s"$SUB_GROUP_MAPILLARY.clientId" + val KEY_MAPILLARY_BORDER = s"$SUB_GROUP_MAPILLARY.border" val GROUP_OSM = "osm" val KEY_OSM_SERVER = s"$GROUP_OSM.server" diff --git a/app/org/maproulette/controllers/api/ChallengeController.scala b/app/org/maproulette/controllers/api/ChallengeController.scala index 5f2f314c0..b364a70b6 100644 --- a/app/org/maproulette/controllers/api/ChallengeController.scala +++ b/app/org/maproulette/controllers/api/ChallengeController.scala @@ -121,6 +121,23 @@ class ChallengeController @Inject() ( this.extractTags(body, createdObject, User.superUser) } + /** + * Overrides the default read method to return a BaseChallenge (flattened structure) + * instead of the nested Challenge structure + * + * @param id The id of the challenge to retrieve + * @return 200 Ok with BaseChallenge json, 404 if not found + */ + override def read(implicit id: Long): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + implicit val baseChallengeWrites = BaseChallenge.baseChallengeWrites + this.dal.retrieveBaseChallengeById match { + case Some(value) => Ok(Json.toJson(value)) + case None => NotFound + } + } + } + /** * Gets a json list of tags of the challenge * @@ -560,6 +577,13 @@ class ChallengeController @Inject() ( } } + def getChallengeTaskMarkers(id: Long): Action[AnyContent] = + Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + Ok(Json.toJson(this.dal.getChallengeTaskMarkers(id))) + } + } + /** * Gets the preferred challenges (hottest, newest, featured) * @@ -1090,6 +1114,71 @@ class ChallengeController @Inject() ( } } + /** + * Efficient endpoint for exploring challenges with specific parameters + * Uses an optimized query path specifically designed for the explore challenges feature + * + * @param global Whether to include global challenges (default: true) + * @param bounds Bounding box as [left,bottom,right,top] to filter challenges by location + * @param sortBy Column to sort by (name, created, modified, popularity, difficulty) + * @param limit Maximum number of results to return + * @param offset Number of results to skip for pagination + * @return A list of challenges matching the criteria + */ + def exploreChallenges( + global: Boolean, + bounds: Option[String], + sortBy: String, + limit: Int, + offset: Int, + keywords: Option[String], + difficulty: Option[Int] + ): Action[AnyContent] = + Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + val boundingBox = bounds.flatMap { b => + b.split(",").map(_.trim).filter(_.nonEmpty).toList match { + case List(left, bottom, right, top) => + Some((left.toDouble, bottom.toDouble, right.toDouble, top.toDouble)) + case _ => None + } + } + + val challenges = this.dal.exploreChallenges( + includeGlobal = global, + boundingBox = boundingBox, + sortBy = sortBy, + limit = limit, + offset = offset, + keywords = keywords, + difficulty = difficulty + ) + + Ok(Json.toJson(challenges)) + } + } + + /** + * Fuzzy search for challenges by ID or name + * Returns a single challenge if found (first match) + * Searches by ID if the search string is numeric, otherwise searches by name using fuzzy matching + * + * @param search The search string (can be ID or name) + * @param onlyEnabled Only include enabled challenges + * @param limit Maximum number of results to return + * @return A single challenge matching the search criteria, or empty list if not found + */ + def search( + search: String, + onlyEnabled: Boolean = false, + limit: Int = 25 + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + val results = this.dal.search(search, limit, onlyEnabled) + Ok(Json.toJson(results)) + } + } + def healthCheck(): Action[AnyContent] = Action { implicit request => Ok(Json.toJson(StatusMessage("OK", JsString("We good")))) @@ -1691,4 +1780,295 @@ class ChallengeController @Inject() ( } } } + + /** + * Favorites (saves) a challenge for the current user + * + * @param challengeId The id of the challenge to favorite + * @return 200 OK with success message + */ + def favoriteChallenge(challengeId: Long): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + this.serviceManager.user.saveChallenge(user.id, challengeId, user) + Ok( + Json.toJson( + StatusMessage("OK", JsString(s"Challenge $challengeId favorited")) + ) + ) + } + } + + /** + * Unfavorites (unsaves) a challenge for the current user + * + * @param challengeId The id of the challenge to unfavorite + * @return 200 OK with success message + */ + def unfavoriteChallenge(challengeId: Long): Action[AnyContent] = Action.async { + implicit request => + this.sessionManager.authenticatedRequest { implicit user => + this.serviceManager.user.unsaveChallenge(user.id, challengeId, user) + Ok( + Json.toJson( + StatusMessage("OK", JsString(s"Challenge $challengeId unfavorited")) + ) + ) + } + } + + /** + * Checks if a challenge is favorited by the current user + * + * @param challengeId The id of the challenge to check + * @return 200 OK with boolean indicating if favorited + */ + def isChallengeFavorited(challengeId: Long): Action[AnyContent] = Action.async { + implicit request => + this.sessionManager.userAwareRequest { implicit user => + user match { + case Some(u) => + val isFavorited = this.serviceManager.user.isChallengeSaved(u.id, challengeId, u) + Ok(Json.toJson(Json.obj("isFavorited" -> isFavorited))) + case None => + Ok(Json.toJson(Json.obj("isFavorited" -> false))) + } + } + } + + /** + * Likes a challenge for the current user + * + * @param challengeId The id of the challenge to like + * @return 200 OK with success message + */ + def likeChallenge(challengeId: Long): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + this.serviceManager.user.likeChallenge(user.id, challengeId, user) + Ok( + Json.toJson( + StatusMessage("OK", JsString(s"Challenge $challengeId liked")) + ) + ) + } + } + + /** + * Unlikes a challenge for the current user + * + * @param challengeId The id of the challenge to unlike + * @return 200 OK with success message + */ + def unlikeChallenge(challengeId: Long): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + this.serviceManager.user.unlikeChallenge(user.id, challengeId, user) + Ok( + Json.toJson( + StatusMessage("OK", JsString(s"Challenge $challengeId unliked")) + ) + ) + } + } + + /** + * Checks if a challenge is liked by the current user + * + * @param challengeId The id of the challenge to check + * @return 200 OK with boolean indicating if liked + */ + def isChallengeLiked(challengeId: Long): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + user match { + case Some(u) => + val isLiked = this.serviceManager.user.isChallengeLiked(u.id, challengeId, u) + Ok(Json.toJson(Json.obj("isLiked" -> isLiked))) + case None => + Ok(Json.toJson(Json.obj("isLiked" -> false))) + } + } + } + + /** + * Gets the total like count for a challenge + * + * @param challengeId The id of the challenge + * @return 200 OK with the like count + */ + def getChallengeLikeCount(challengeId: Long): Action[AnyContent] = Action.async { + implicit request => + this.sessionManager.userAwareRequest { implicit user => + val likeCount = this.serviceManager.user.getChallengeLikeCount(challengeId) + Ok(Json.toJson(Json.obj("likeCount" -> likeCount))) + } + } + + /** + * Create-or-update: if the JSON body includes a numeric `id`, delegate to + * update; otherwise delegate to create. Keeps clients from needing to pick + * between POST /challenge and PUT /challenge/:id based on whether they + * have a persisted id yet. + */ + def saveOrUpdate: Action[JsValue] = Action.async(bodyParsers.json) { implicit request => + val rawId = (request.body \ "id").asOpt[Long].getOrElse(-1L) + if (rawId > 0) { + this.update(rawId).apply(request) + } else { + this.create.apply(request) + } + } + + /** + * Narrow update endpoint that accepts just the priority-related fields on + * a challenge and nothing else. Avoids the side effects (re-validation, + * task-rebuild triggers) that the full PUT /challenge/:id performs when + * unrelated fields are absent or differ. + */ + def updatePriorities(id: Long): Action[JsValue] = Action.async(bodyParsers.json) { + implicit request => + this.sessionManager.authenticatedRequest { implicit user => + val challenge = this.dal.retrieveById(id) match { + case Some(c) => c + case None => + throw new NotFoundException(s"Challenge with id $id not found, unable to update.") + } + permission.hasWriteAccess(ProjectType(), user)(challenge.general.parent) + + val allowedKeys = Set( + "defaultPriority", + "highPriorityRule", + "highPriorityBounds", + "mediumPriorityRule", + "mediumPriorityBounds", + "lowPriorityRule", + "lowPriorityBounds" + ) + val body = request.body.asOpt[JsObject].getOrElse(Json.obj()) + val filtered = JsObject( + body.fields.filter { case (k, _) => allowedKeys.contains(k) } + ) + + if (filtered.fields.isEmpty) { + BadRequest( + Json.toJson( + StatusMessage( + "KO", + JsString( + s"Body must include at least one of: ${allowedKeys.mkString(", ")}" + ) + ) + ) + ) + } else { + this.dal.update(filtered, user)(id) match { + case Some(updated) => + val (highWrites, mediumWrites, lowWrites) = + this.dal.updateTaskPriorities(user, overrideValidation = true)(id) + this.dalManager.task.clearCaches + this.dal.clearCaches + // Surface an honest receipt of what the recompute did. `tasksWritten` + // is the net change in the task count at each tier (post minus pre): + // positive means tasks were promoted into the tier, negative means + // tasks left it. All zeros means the distribution didn't shift — + // either nothing matched, or movements perfectly offset. + val postCounts: Map[Int, Long] = this.dal.countTasksByPriority(id) + val highCount: Long = postCounts.getOrElse(Challenge.PRIORITY_HIGH, 0L) + val mediumCount: Long = postCounts.getOrElse(Challenge.PRIORITY_MEDIUM, 0L) + val lowCount: Long = postCounts.getOrElse(Challenge.PRIORITY_LOW, 0L) + val receipt = Json.obj( + "tasksWritten" -> Json.obj( + "high" -> highWrites, + "medium" -> mediumWrites, + "low" -> lowWrites + ), + "tasksByPriority" -> Json.obj( + "high" -> highCount, + "medium" -> mediumCount, + "low" -> lowCount + ) + ) + Ok(Json.toJson(updated).as[JsObject] ++ Json.obj("priorityRecompute" -> receipt)) + case None => + InternalServerError(Json.toJson(StatusMessage("KO", JsString("Update failed")))) + } + } + } + } + + /** + * Dry-run sibling of `updatePriorities`. Accepts the same body shape but + * does not persist anything — instead, it returns the priority each task + * WOULD receive under the supplied draft config. The editor uses this to + * power its live preview (pin colors, match counts), so what the user sees + * on the map is byte-for-byte what a subsequent save would write. + */ + def previewPriorities(id: Long): Action[JsValue] = Action.async(bodyParsers.json) { + implicit request => + this.sessionManager.authenticatedRequest { implicit user => + val challenge = this.dal.retrieveById(id) match { + case Some(c) => c + case None => + throw new NotFoundException(s"Challenge with id $id not found.") + } + permission.hasWriteAccess(ProjectType(), user)(challenge.general.parent) + + val body = request.body + val existing = challenge.priority + // `filter(_.nonEmpty)` collapses empty-string/empty-array sentinels the + // frontend omits — matches the DAL's save-path interpretation so a rule + // cleared in the editor previews as a rule cleared in the DB. + val draft = ChallengePriority( + defaultPriority = + (body \ "defaultPriority").asOpt[Int].getOrElse(existing.defaultPriority), + highPriorityRule = (body \ "highPriorityRule") + .asOpt[String] + .map(_.trim) + .filter(s => s.nonEmpty && s != "{}"), + mediumPriorityRule = (body \ "mediumPriorityRule") + .asOpt[String] + .map(_.trim) + .filter(s => s.nonEmpty && s != "{}"), + lowPriorityRule = (body \ "lowPriorityRule") + .asOpt[String] + .map(_.trim) + .filter(s => s.nonEmpty && s != "{}"), + highPriorityBounds = (body \ "highPriorityBounds") + .asOpt[String] + .map(_.trim) + .filter(s => s.nonEmpty && s != "[]"), + mediumPriorityBounds = (body \ "mediumPriorityBounds") + .asOpt[String] + .map(_.trim) + .filter(s => s.nonEmpty && s != "[]"), + lowPriorityBounds = (body \ "lowPriorityBounds") + .asOpt[String] + .map(_.trim) + .filter(s => s.nonEmpty && s != "[]") + ) + + val priorities = this.dal.previewTaskPriorities(user, draft)(id) + // Aggregate counts once on the server so the client doesn't have to + // walk the per-task map just to populate the badges. + var high = 0 + var medium = 0 + var low = 0 + priorities.foreach { + case (_, Challenge.PRIORITY_HIGH) => high += 1 + case (_, Challenge.PRIORITY_MEDIUM) => medium += 1 + case (_, Challenge.PRIORITY_LOW) => low += 1 + case _ => () + } + Ok( + Json.obj( + "priorities" -> JsObject(priorities.map { + case (taskId, p) => + taskId.toString -> JsNumber(p) + }), + "counts" -> Json.obj( + "high" -> high, + "medium" -> medium, + "low" -> low + ) + ) + ) + } + } } diff --git a/app/org/maproulette/controllers/api/TaskController.scala b/app/org/maproulette/controllers/api/TaskController.scala index f5571effe..dd4042c2b 100644 --- a/app/org/maproulette/controllers/api/TaskController.scala +++ b/app/org/maproulette/controllers/api/TaskController.scala @@ -25,6 +25,7 @@ import org.maproulette.framework.service.{ServiceManager, TagService, TaskCluste import org.maproulette.framework.mixins.TagsControllerMixin import org.maproulette.framework.repository.TaskRepository import org.maproulette.metrics.Metrics +import org.maproulette.permissions.Permission import org.maproulette.models.dal.mixin.TagDALMixin import org.maproulette.models.dal.{DALManager, TaskDAL} import org.maproulette.provider.osm._ @@ -69,7 +70,8 @@ class TaskController @Inject() ( changeService: ChangesetProvider, taskClusterService: TaskClusterService, override val bodyParsers: PlayBodyParsers, - taskRepository: TaskRepository + taskRepository: TaskRepository, + permission: Permission ) extends AbstractController(components) with CRUDController[Task] with TagsControllerMixin[Task] { @@ -131,46 +133,21 @@ class TaskController @Inject() ( this.updateGeometryData(super.updateUpdateBody(body, user)) private def updateGeometryData(body: JsValue): JsValue = { - val updatedBody = (body \ "geometries").asOpt[String] match { - case Some(value) => - // if it is a string, then it is either GeoJSON or a WKB - // just check to see if { is the first character and then we can assume it is GeoJSON - if (value.charAt(0) != '{') { - // TODO: - body - } else { - // just return the body because it handles this case correctly - body - } - case None => - // if it maps to None then it simply could be that it is a JSON object - (body \ "geometries").asOpt[JsValue] match { - case Some(value) => - // need to convert to a string for the case class otherwise validation will fail - Utils.insertIntoJson(body, "geometries", value.toString(), true) - case None => - // if the geometries are not supplied then just leave it - body - } - } - (updatedBody \ "location").asOpt[String] match { - case Some(value) => updatedBody - case None => - (updatedBody \ "location").asOpt[JsValue] match { - case Some(value) => - Utils.insertIntoJson(updatedBody, "location", value.toString(), true) - case None => updatedBody - } - } - (updatedBody \ "cooperativeWork").asOpt[String] match { - case Some(value) => updatedBody - case None => - (updatedBody \ "cooperativeWork").asOpt[JsValue] match { - case Some(value) => - Utils.insertIntoJson(updatedBody, "cooperativeWork", value.toString(), true) - case None => updatedBody - } - } + // Detect JsValues that are strings containing embedded JSON objects, + // and parse them to real JsValues instead. This is so that old clients + // which submit task geometries, location, etc as stringified JSON instead + // of proper nested objects still work. + def normalize(json: JsValue, key: String): JsValue = + (json \ key).toOption match { + case Some(JsString(value)) if value.nonEmpty && value.charAt(0) == '{' => + Utils.insertIntoJson(json, key, Json.parse(value), true) + case _ => json + } + + val withGeometries = normalize(body, "geometries") + val withLocation = normalize(withGeometries, "location") + val withCooperativeWork = normalize(withLocation, "cooperativeWork") + withCooperativeWork } /** @@ -297,8 +274,7 @@ class TaskController @Inject() ( val xml = task.cooperativeWork match { case Some(cw) => - val cooperativeWork = Json.parse(cw) - (cooperativeWork \ "file" \ "content").asOpt[String] match { + (cw \ "file" \ "content").asOpt[String] match { case Some(base64EncodedXML) => new String(java.util.Base64.getDecoder.decode(base64EncodedXML)) case None => throw new NotFoundException(s"Task $taskId does not offer change XML.") @@ -369,6 +345,27 @@ class TaskController @Inject() ( } } + /** + * Retrieves multiple tasks by their IDs. + * + * @param taskIds Comma-separated string of task IDs to retrieve + * @return Array of Task objects + */ + def getTasks(taskIds: String): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + val taskIdsList = Utils.toLongList(taskIds).getOrElse(List.empty[Long]) + if (taskIdsList.isEmpty) { + BadRequest(Json.toJson(StatusMessage("KO", JsString("taskIds array cannot be empty")))) + } else { + val tasks = taskIdsList.flatMap(taskId => this.dal.retrieveById(taskId)) + // Inject extra data (tags, mapillary) for each task, similar to the read method + // inject returns JsValue, so we collect them into a JsArray + val tasksWithInjectedData = tasks.map(task => this.inject(task)) + Ok(JsArray(tasksWithInjectedData)) + } + } + } + /** * Locks a bundle of tasks based on the provided task IDs. * @@ -515,7 +512,7 @@ class TaskController @Inject() ( if (request.getQueryString("mapillary").getOrElse("false").toBoolean) { // build the envelope for the task geometries val taskFeatureCollection = - GeoJSONFactory.create(obj.geometries).asInstanceOf[FeatureCollection] + GeoJSONFactory.create(Json.stringify(obj.geometries)).asInstanceOf[FeatureCollection] val reader = new GeoJSONReader() val envelope = new Envelope() taskFeatureCollection.getFeatures.foreach(f => { @@ -825,4 +822,120 @@ class TaskController @Inject() ( this.addTagstoItem(taskId, tagList.map(new Tag(-1, _, tagType = this.dal.tableName)), user) } } + + def search( + search: String, + limit: Int = 25 + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + val results = this.dal.search(search, limit) + Ok(Json.toJson(results)) + } + } + + /** + * Skip a task: increments skip_count, releases the caller's lock, and + * leaves status unchanged. Emits a task-released WebSocket event so + * map / table clients can update their lock view. + */ + def skipTask(taskId: Long): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + val task = this.dal.retrieveById(taskId) match { + case Some(t) => t + case None => throw new NotFoundException(s"Task with $taskId not found, unable to skip.") + } + + taskRepository.incrementSkipCount(taskId) + + try { + this.dal.unlockItem(user, task) + webSocketProvider.sendMessage( + WebSocketMessages.taskReleased(task, Some(WebSocketMessages.userSummary(user))) + ) + } catch { + case e: Exception => logger.warn(s"Skip unlock failed for task $taskId: ${e.getMessage}") + } + + NoContent + } + } + + /** + * Bulk delete: removes every task in the supplied `taskIds` list. + * Fails with 403 if the caller lacks write access to any task's parent + * project, or 404 if any id is missing. + */ + def bulkDelete: Action[JsValue] = Action.async(bodyParsers.json) { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + val taskIds = (request.body \ "taskIds").asOpt[List[Long]].getOrElse(List.empty) + if (taskIds.isEmpty) { + BadRequest(Json.toJson(StatusMessage("KO", JsString("taskIds must be a non-empty array")))) + } else { + val tasks = resolveTasksWithWriteAccess(taskIds, user) + val deleted = taskRepository.bulkDeleteTasks(tasks.map(_.id)) + Ok(Json.obj("requested" -> taskIds.length, "deleted" -> deleted)) + } + } + } + + /** + * Bulk archive / unarchive tasks. Same access semantics as `bulkDelete`: + * 403 on any unauthorized task, 404 on any missing id. + */ + def bulkArchive: Action[JsValue] = Action.async(bodyParsers.json) { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + val taskIds = (request.body \ "taskIds").asOpt[List[Long]].getOrElse(List.empty) + val archived = (request.body \ "archived").asOpt[Boolean].getOrElse(true) + if (taskIds.isEmpty) { + BadRequest(Json.toJson(StatusMessage("KO", JsString("taskIds must be a non-empty array")))) + } else { + val tasks = resolveTasksWithWriteAccess(taskIds, user) + taskRepository.bulkArchiveTasks(tasks.map(_.id), archived) + webSocketProvider.sendMessage( + WebSocketMessages.tasksUpdated(tasks, Some(WebSocketMessages.userSummary(user))) + ) + NoContent + } + } + } + + /** + * Bulk reassign the reviewer on each task in `taskIds` to `userId`. + * Only tasks whose reviews are still open (status 0 or 3) are updated. + */ + def bulkReassign: Action[JsValue] = Action.async(bodyParsers.json) { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + val taskIds = (request.body \ "taskIds").asOpt[List[Long]].getOrElse(List.empty) + val userId = (request.body \ "userId").asOpt[Long].getOrElse(-1L) + if (taskIds.isEmpty || userId < 0) { + BadRequest( + Json.toJson(StatusMessage("KO", JsString("taskIds and userId are required"))) + ) + } else { + val tasks = resolveTasksWithWriteAccess(taskIds, user) + val updated = taskRepository.bulkReassignReviewer(tasks.map(_.id), userId) + Ok(Json.obj("requested" -> taskIds.length, "updated" -> updated)) + } + } + } + + /** + * Resolve each id to a task and assert the caller has write access on its + * parent project. Throws `NotFoundException` for any missing task or + * orphaned challenge, and `IllegalAccessException` from the first denial + * — both are mapped to 404/403 by the framework's exception handler. + */ + private def resolveTasksWithWriteAccess(taskIds: List[Long], user: User): List[Task] = + taskIds.distinct.map { taskId => + val task = this.dal + .retrieveById(taskId) + .getOrElse(throw new NotFoundException(s"Task $taskId not found")) + val challenge = dalManager.challenge + .retrieveById(task.parent) + .getOrElse( + throw new NotFoundException(s"Parent challenge ${task.parent} for task $taskId not found") + ) + permission.hasWriteAccess(ProjectType(), user)(challenge.general.parent) + task + } } diff --git a/app/org/maproulette/exception/StatusMessage.scala b/app/org/maproulette/exception/StatusMessage.scala index c6610ecbc..e66068309 100644 --- a/app/org/maproulette/exception/StatusMessage.scala +++ b/app/org/maproulette/exception/StatusMessage.scala @@ -14,6 +14,9 @@ trait StatusMessages { implicit val statusMessageReads = StatusMessage.statusMessageReads } +// TODO: `message` is JsValue because callers pass both JsString and JsObject. +// We should fix this polymorphism so that we can make the Swagger type for it +// stricter/more accurate. case class StatusMessage(status: String, message: JsValue) object StatusMessage { diff --git a/app/org/maproulette/framework/controller/CommentController.scala b/app/org/maproulette/framework/controller/CommentController.scala index d6c6b3991..140814ca3 100644 --- a/app/org/maproulette/framework/controller/CommentController.scala +++ b/app/org/maproulette/framework/controller/CommentController.scala @@ -124,6 +124,42 @@ class CommentController @Inject() ( } } + /** + * Searches all task comments by a search term + * + * @param q The search term + * @param limit The maximum number of comments to return + * @param page The page number for pagination + * @return A list of matching comments + */ + def searchComments( + q: String, + limit: Int = 25, + page: Int = 0 + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + Ok(Json.toJson(this.commentService.searchComments(q, user, limit, page))) + } + } + + /** + * Searches all challenge comments by a search term + * + * @param q The search term + * @param limit The maximum number of comments to return + * @param page The page number for pagination + * @return A list of matching challenge comments + */ + def searchChallengeComments( + q: String, + limit: Int = 25, + page: Int = 0 + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.authenticatedRequest { implicit user => + Ok(Json.toJson(this.commentService.searchChallengeComments(q, user, limit, page))) + } + } + /** * Adds a comment for a specific task * diff --git a/app/org/maproulette/framework/controller/ProjectController.scala b/app/org/maproulette/framework/controller/ProjectController.scala index d0379d326..72a43d62d 100644 --- a/app/org/maproulette/framework/controller/ProjectController.scala +++ b/app/org/maproulette/framework/controller/ProjectController.scala @@ -211,6 +211,27 @@ class ProjectController @Inject() ( } } + /** + * Fuzzy search for projects by ID or name + * Returns a single project if found (first match) + * Searches by ID if the search string is numeric, otherwise searches by name using fuzzy matching + * + * @param search The search string (can be ID or name) + * @param onlyEnabled Only include enabled projects + * @param limit Maximum number of results to return + * @return A single project matching the search criteria, or empty list if not found + */ + def search( + search: String, + onlyEnabled: Boolean = false, + limit: Int = 25 + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + val results = this.projectService.search(search, limit, onlyEnabled) + Ok(Json.toJson(results)) + } + } + /** * Retrieves the list of projects managed * diff --git a/app/org/maproulette/framework/controller/SearchController.scala b/app/org/maproulette/framework/controller/SearchController.scala new file mode 100644 index 000000000..37d5e460a --- /dev/null +++ b/app/org/maproulette/framework/controller/SearchController.scala @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ +package org.maproulette.framework.controller + +import javax.inject.Inject +import org.maproulette.data.ActionManager +import org.maproulette.framework.model.{Challenge, Project} +import org.maproulette.framework.service.ProjectService +import org.maproulette.models.dal.{ChallengeDAL, TaskDAL} +import org.maproulette.session.SessionManager +import play.api.libs.json._ +import play.api.mvc._ + +class SearchController @Inject() ( + override val sessionManager: SessionManager, + override val actionManager: ActionManager, + override val bodyParsers: PlayBodyParsers, + projectService: ProjectService, + challengeDAL: ChallengeDAL, + taskDAL: TaskDAL, + components: ControllerComponents +) extends AbstractController(components) + with MapRouletteController { + + implicit val challengeWrites: Writes[Challenge] = Challenge.writes.challengeWrites + implicit val projectWrites: Writes[Project] = Project.writes + + def search(q: String, limit: Int = 25): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + val projects = Json.toJson(projectService.search(q, limit)) + val challenges = Json.toJson(challengeDAL.search(q, limit)) + val tasks = Json.toJson(taskDAL.search(q, limit)) + Ok( + Json.obj( + "projects" -> projects, + "challenges" -> challenges, + "tasks" -> tasks + ) + ) + } + } + + def searchById(id: Long): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + val project = projectService.retrieve(id) match { + case Some(p) => + Json.obj( + "id" -> p.id, + "name" -> p.name, + "displayName" -> p.displayName, + "description" -> p.description + ) + case None => JsNull + } + + val challenge = challengeDAL.retrieveById(id) match { + case Some(c) => + Json.obj( + "id" -> c.id, + "name" -> c.name, + "description" -> c.description + ) + case None => JsNull + } + + val task = taskDAL.retrieveById(id) match { + case Some(t) => + Json.obj( + "id" -> t.id, + "name" -> t.name, + "status" -> t.status, + "parent" -> t.parent + ) + case None => JsNull + } + + Ok( + Json.obj( + "project" -> project, + "challenge" -> challenge, + "task" -> task + ) + ) + } + } +} diff --git a/app/org/maproulette/framework/controller/TaskController.scala b/app/org/maproulette/framework/controller/TaskController.scala index 4517062bf..b74cc5f84 100644 --- a/app/org/maproulette/framework/controller/TaskController.scala +++ b/app/org/maproulette/framework/controller/TaskController.scala @@ -14,7 +14,7 @@ import org.maproulette.framework.service.{ NotificationService } import org.maproulette.framework.psql.Paging -import org.maproulette.framework.model.User +import org.maproulette.framework.model.{User, TaskMarkerResponse} import org.maproulette.framework.mixins.TaskJSONMixin import org.maproulette.session.{SessionManager, SearchParameters, SearchLocation} import play.api.mvc._ @@ -128,6 +128,69 @@ class TaskController @Inject() ( } } + /** + * Gets challenge tasks within a bounding box (simplified endpoint) + * + * @param bounds Comma-separated bounding box coordinates: "west,south,east,north" + * @param challengeIds Comma-separated list of challenge IDs to filter by + * @param limit Maximum number of tasks to return per page + * @param page Page number for pagination (0-indexed) + * @return Paginated list of tasks with total count + */ + def getChallengeTasksInBounds( + bounds: String, + challengeIds: String, + limit: Int, + page: Int + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + val result: Either[String, JsObject] = + for { + location <- parseBounds(bounds) + challenges <- parseChallengeIds(challengeIds) + } yield { + val (count, tasks) = this.taskClusterService.getChallengeTasksInBounds( + location, + challenges, + Paging(limit, page) + ) + val resultJson = + this.insertExtraTaskJSON(tasks, includeGeometries = false, includeTags = false) + Json.obj( + "data" -> resultJson, + "total" -> count, + "page" -> page, + "limit" -> limit + ) + } + + result match { + case Left(msg) => BadRequest(Json.obj("error" -> msg)) + case Right(json) => Ok(json) + } + } + } + + private def parseBounds(bounds: String): Either[String, Option[SearchLocation]] = + if (bounds.isEmpty) Right(None) + else + bounds.split(",").map(_.trim).map(_.toDoubleOption) match { + case Array(Some(w), Some(s), Some(e), Some(n)) => + Right(Some(SearchLocation(w, s, e, n))) + case _ => + Left(s"Invalid bounds '$bounds'; expected numeric 'west,south,east,north'") + } + + private def parseChallengeIds(challengeIds: String): Either[String, Option[List[Long]]] = + if (challengeIds.isEmpty) Right(None) + else { + val parsed = challengeIds.split(",").map(_.trim).map(_.toLongOption) + if (parsed.forall(_.isDefined)) + Right(Some(parsed.flatten.toList)) + else + Left(s"Invalid challengeIds '$challengeIds'; expected a comma-separated list of integers") + } + /** * Gets all the task markers within a bounding box * @@ -165,6 +228,138 @@ class TaskController @Inject() ( } } + def getTaskMarkers( + statuses: String, + global: Boolean, + cluster: Boolean, + bounds: Option[String], + keywords: Option[String], + difficulty: Option[Int] + ): Action[AnyContent] = Action.async { implicit request => + this.sessionManager.userAwareRequest { implicit user => + SearchParameters.withSearch { p => + val statusList = if (statuses.isEmpty) { + List.empty[Int] + } else { + statuses.split(",").map(_.trim.toInt).toList + } + + val boundingBox = bounds match { + case Some(b) => + b.split(",").map(_.trim.toDouble).toList match { + case List(left, bottom, right, top) => + SearchLocation(left, bottom, right, top) + case _ => SearchLocation(-180.0, -90.0, 180.0, 90.0) + } + case None => SearchLocation(-180.0, -90.0, 180.0, 90.0) + } + + val taskCount = this.taskClusterService.countTaskMarkers( + statusList, + global, + boundingBox, + keywords, + difficulty + ) + + if (taskCount > 5000) { + Ok( + Json.toJson( + TaskMarkerResponse( + totalCount = taskCount, + tasks = None, + clusters = None + ) + ) + ) + } else if ((cluster || taskCount > 500) && !(taskCount < 100)) { + val clusters = this.taskClusterService.getTaskMarkersClustered( + statusList, + global, + boundingBox, + keywords, + difficulty + ) + Ok( + Json.toJson( + TaskMarkerResponse( + totalCount = taskCount, + tasks = None, + clusters = Some(clusters) + ) + ) + ) + } else { + val (singleMarkers, overlappingMarkers) = + this.taskClusterService.getTaskMarkersWithOverlaps( + statusList, + global, + boundingBox, + keywords, + difficulty + ) + Ok( + Json.toJson( + TaskMarkerResponse( + totalCount = taskCount, + tasks = Some(singleMarkers), + overlappingTasks = + if (overlappingMarkers.nonEmpty) Some(overlappingMarkers) else None, + clusters = None + ) + ) + ) + } + } + } + } + + /** + * Get MVT (Mapbox Vector Tile) for a specific tile. + * Returns binary protobuf data for use with MapLibre vector tile sources. + * + * @param z Zoom level (0-22, MapLibre overzooms past the + * precomputed ceiling) + * @param x Tile X coordinate + * @param y Tile Y coordinate + * @param global Include global challenges + * @param difficulty Optional difficulty filter (1=Easy, 2=Normal, 3=Expert) + * @return Binary MVT data + */ + def getTaskTilesMvt( + z: Int, + x: Int, + y: Int, + global: Boolean, + difficulty: Option[Int], + keywords: Option[String] + ): Action[AnyContent] = Action { implicit request => + val validZoom = math.max(0, math.min(22, z)) + val validDifficulty = difficulty.filter(d => d >= 1 && d <= 3) + + val mvtBytes = this.serviceManager.tileAggregate.getMvtTile( + validZoom, + x, + y, + validDifficulty, + global, + keywords + ) + + // A tile is a pure function of (z, x, y) and the filter params — nothing + // in it depends on the requesting user — so every non-empty tile is + // publicly cacheable, filtered or not. The window is kept short (≈ rebuild + // cadence) so mutations become visible quickly. Empty tiles aren't cached: + // they may start containing data on the next rebuild. + val cacheControl = + if (mvtBytes.isEmpty) "no-store" + else "public, max-age=10, must-revalidate" + + Ok(mvtBytes) + .as("application/vnd.mapbox-vector-tile") + .withHeaders("Cache-Control" -> cacheControl) + } + /** * Updates the completion responses asked in the task instructions. Request * body should include the reponse JSON. diff --git a/app/org/maproulette/framework/controller/UserController.scala b/app/org/maproulette/framework/controller/UserController.scala index 32a449422..abefe80c1 100644 --- a/app/org/maproulette/framework/controller/UserController.scala +++ b/app/org/maproulette/framework/controller/UserController.scala @@ -7,7 +7,8 @@ package org.maproulette.framework.controller import javax.inject.Inject import org.maproulette.exception.{InvalidException, NotFoundException, StatusMessage} import org.maproulette.framework.model.{Challenge, User, UserSettings, GrantTarget, Task} -import org.maproulette.framework.psql.Paging +import org.maproulette.framework.psql.{Paging, Query, Order} +import org.maproulette.framework.psql.filter.{BaseParameter, Operator} import org.maproulette.framework.service.ServiceManager import org.maproulette.framework.mixins.ParentMixin import org.maproulette.permissions.Permission @@ -67,7 +68,7 @@ class UserController @Inject() ( def whoami(): Action[AnyContent] = Action.async { implicit request => this.sessionManager.authenticatedRequest { implicit user => - Ok(Json.toJson(user)) + Ok(Json.toJson(User.withDecryptedAPIKey(user)(crypto))) } } @@ -578,6 +579,33 @@ class UserController @Inject() ( } } + /** + * Super Admin endpoint to get all users with pagination + * @param limit the maximum number of users to return per page + * @param page the page number (0-indexed) + * @return A paginated list of all users + */ + def getAllUsersForSuperAdmin(limit: Int, page: Int): Action[AnyContent] = + Action.async { implicit request => + implicit val requireSuperUser: Boolean = true + this.sessionManager.authenticatedRequest { implicit user => + // Ensure only super admins can access this endpoint + this.permission.hasSuperAccess(user) + + // Query users with pagination, excluding the system super user (id = -999) + val users = this.serviceManager.user.query( + Query.simple( + List(BaseParameter(User.FIELD_ID, -999, Operator.NE)), + paging = Paging(limit, page), + order = Order > (User.FIELD_ID) + ), + user + ) + + Ok(Json.toJson(users)) + } + } + /** * Promotes a user to a super user * @param maprouletteUserId the maproulette user id to promote diff --git a/app/org/maproulette/framework/graphql/schemas/MRSchema.scala b/app/org/maproulette/framework/graphql/schemas/MRSchema.scala index 64b1b8e54..6f1009eeb 100644 --- a/app/org/maproulette/framework/graphql/schemas/MRSchema.scala +++ b/app/org/maproulette/framework/graphql/schemas/MRSchema.scala @@ -75,3 +75,7 @@ case object DateTimeCoerceViolation extends Violation { case object ArrayLongCoerceViolation extends Violation { override def errorMessage: String = "Error during parsing Array[Long]" } + +case object JsonCoerceViolation extends Violation { + override def errorMessage: String = "Not valid JSON" +} diff --git a/app/org/maproulette/framework/graphql/schemas/MRSchemaTypes.scala b/app/org/maproulette/framework/graphql/schemas/MRSchemaTypes.scala index bfacf0c84..7da59f5f0 100644 --- a/app/org/maproulette/framework/graphql/schemas/MRSchemaTypes.scala +++ b/app/org/maproulette/framework/graphql/schemas/MRSchemaTypes.scala @@ -41,6 +41,30 @@ trait MRSchemaTypes { } ) + // GraphQL doesn't have a native JSON type, so we expose embedded JSON + // fields as custom scalars. Scalar names were chosen to be compatible + // with https://www.npmjs.com/package/graphql-type-json + implicit val graphQLJsObject: ScalarType[JsObject] = ScalarType[JsObject]( + "JSONObject", + coerceOutput = (value, _) => value, + coerceUserInput = { + case v: JsObject => Right(v) + case _ => Left(JsonCoerceViolation) + }, + coerceInput = _ => Left(JsonCoerceViolation) + ) + implicit val graphQLJsArray: ScalarType[JsArray] = ScalarType[JsArray]( + "JSONArray", + coerceOutput = (value, _) => value, + coerceUserInput = { + case v: JsArray => Right(v) + case _ => Left(JsonCoerceViolation) + }, + coerceInput = _ => Left(JsonCoerceViolation) + ) + + implicit val CompletionMetricsType: ObjectType[Unit, CompletionMetrics] = + deriveObjectType[Unit, CompletionMetrics](ObjectTypeName("CompletionMetrics")) // Project Types implicit lazy val ProjectType: ObjectType[Unit, Project] = deriveObjectType[Unit, Project](ObjectTypeName("Project")) diff --git a/app/org/maproulette/framework/graphql/schemas/ProjectSchema.scala b/app/org/maproulette/framework/graphql/schemas/ProjectSchema.scala index 3c0ae2d56..97829f736 100644 --- a/app/org/maproulette/framework/graphql/schemas/ProjectSchema.scala +++ b/app/org/maproulette/framework/graphql/schemas/ProjectSchema.scala @@ -105,7 +105,7 @@ object ProjectSchema { implicit val ProjectInputType: InputObjectType[Project] = deriveInputObjectType[Project]( InputObjectTypeName("ProjectInput"), InputObjectTypeDescription("A project in MapRoulette"), - ExcludeInputFields("created", "modified", "grants") + ExcludeInputFields("created", "modified", "grants", "completionMetrics") ) val projectIdArg: Argument[Long] = Argument("projectId", LongType, "The project identifier") diff --git a/app/org/maproulette/framework/mixins/TaskJSONMixin.scala b/app/org/maproulette/framework/mixins/TaskJSONMixin.scala index adcfaf03e..32267026b 100644 --- a/app/org/maproulette/framework/mixins/TaskJSONMixin.scala +++ b/app/org/maproulette/framework/mixins/TaskJSONMixin.scala @@ -129,8 +129,8 @@ trait TaskJSONMixin { } if (includeGeometries) { - val geometries = Json.parse(taskDetailsMap(task.id).geometries) - updated = Utils.insertIntoJson(updated, "geometries", geometries, true) + updated = + Utils.insertIntoJson(updated, "geometries", taskDetailsMap(task.id).geometries, true) } if (includeTags) { diff --git a/app/org/maproulette/framework/mixins/TaskParserMixin.scala b/app/org/maproulette/framework/mixins/TaskParserMixin.scala index 55bf3fdf6..fe5a30363 100644 --- a/app/org/maproulette/framework/mixins/TaskParserMixin.scala +++ b/app/org/maproulette/framework/mixins/TaskParserMixin.scala @@ -7,6 +7,7 @@ package org.maproulette.framework.mixins import anorm.SqlParser.get import anorm.{RowParser, ~} import org.joda.time.DateTime +import play.api.libs.json.{JsObject, Json} import org.maproulette.framework.model.{TaskReview, TaskReviewFields, TaskWithReview, Task} @@ -27,13 +28,7 @@ trait TaskParserMixin { "task_review.review_claimed_by, task_review.review_claimed_at, task_review.additional_reviewers, task_review.error_tags " // The anorm row parser to convert records from the task table to task objects - def getTaskParser( - updateAndRetrieve: (Long, Option[String], Option[String], Option[String]) => ( - String, - Option[String], - Option[String] - ) - ): RowParser[Task] = { + def getTaskParser(): RowParser[Task] = { get[Long]("tasks.id") ~ get[String]("tasks.name") ~ get[DateTime]("tasks.created") ~ @@ -42,7 +37,7 @@ trait TaskParserMixin { get[Option[String]]("tasks.instruction") ~ get[Option[String]]("geo_location") ~ get[Option[Int]]("tasks.status") ~ - get[Option[String]]("geo_json") ~ + get[String]("geo_json") ~ get[Option[String]]("cooperative_work") ~ get[Option[DateTime]]("tasks.mapped_on") ~ get[Option[Long]]("tasks.completed_time_spent") ~ @@ -63,13 +58,15 @@ trait TaskParserMixin { get[Option[String]]("responses") ~ get[Option[Long]]("tasks.bundle_id") ~ get[Option[Boolean]]("tasks.is_bundle_primary") ~ - get[Option[String]]("task_review.error_tags") map { + get[Option[String]]("task_review.error_tags") ~ + get[Option[Int]]("tasks.skip_count") ~ + get[Option[Boolean]]("tasks.archived") map { case id ~ name ~ created ~ modified ~ parent_id ~ instruction ~ location ~ status ~ geojson ~ cooperativeWork ~ mappedOn ~ completedTimeSpent ~ completedBy ~ reviewStatus ~ reviewRequestedBy ~ reviewedBy ~ reviewedAt ~ metaReviewedBy ~ metaReviewStatus ~ metaReviewedAt ~ reviewStartedAt ~ reviewClaimedBy ~ reviewClaimedAt ~ - additionalReviewers ~ priority ~ changesetId ~ responses ~ bundleId ~ isBundlePrimary ~ errorTags => - val values = updateAndRetrieve(id, geojson, location, cooperativeWork) + additionalReviewers ~ priority ~ changesetId ~ responses ~ bundleId ~ isBundlePrimary ~ errorTags ~ + skipCount ~ archived => Task( id, name, @@ -77,9 +74,9 @@ trait TaskParserMixin { modified, parent_id, instruction, - values._2, - values._1, - values._3, + location.map(Json.parse(_).as[JsObject]), + Json.parse(geojson).as[JsObject], + cooperativeWork.map(Json.parse(_).as[JsObject]), status, mappedOn, completedTimeSpent, @@ -102,18 +99,14 @@ trait TaskParserMixin { responses, bundleId, isBundlePrimary, - errorTags = errorTags.getOrElse("") + errorTags = errorTags.getOrElse(""), + skipCount = skipCount.getOrElse(0), + archived = archived.getOrElse(false) ) } } - def getTaskWithReviewParser( - updateAndRetrieve: (Long, Option[String], Option[String], Option[String]) => ( - String, - Option[String], - Option[String] - ) - ): RowParser[TaskWithReview] = { + def getTaskWithReviewParser(): RowParser[TaskWithReview] = { // tasks fields get[Long]("tasks.id") ~ get[String]("tasks.name") ~ @@ -123,7 +116,7 @@ trait TaskParserMixin { get[Option[String]]("tasks.instruction") ~ get[Option[String]]("geo_location") ~ get[Option[Int]]("tasks.status") ~ - get[Option[String]]("geo_json") ~ + get[String]("geo_json") ~ get[Option[String]]("cooperative_work") ~ get[Option[DateTime]]("tasks.mapped_on") ~ get[Option[Long]]("tasks.completed_time_spent") ~ @@ -146,6 +139,8 @@ trait TaskParserMixin { get[Option[DateTime]]("task_review.review_claimed_at") ~ get[Option[List[Long]]]("task_review.additional_reviewers") ~ get[Option[String]]("task_review.error_tags") ~ + get[Option[Int]]("tasks.skip_count") ~ + get[Option[Boolean]]("tasks.archived") ~ // challenges and projects fields get[Option[String]]("challenge_name") ~ get[Option[String]]("project_name") ~ @@ -159,9 +154,9 @@ trait TaskParserMixin { reviewStatus ~ reviewRequestedBy ~ reviewedBy ~ reviewedAt ~ metaReviewedBy ~ metaReviewStatus ~ metaReviewedAt ~ reviewStartedAt ~ reviewClaimedBy ~ reviewClaimedAt ~ additionalReviewers ~ errorTags ~ + skipCount ~ archived ~ challengeName ~ projectName ~ projectId ~ reviewRequestedByUsername ~ reviewedByUsername => - val values = updateAndRetrieve(id, geojson, location, cooperativeWork) TaskWithReview( Task( id, @@ -170,9 +165,9 @@ trait TaskParserMixin { modified, parent_id, instruction, - values._2, - values._1, - values._3, + location.map(Json.parse(_).as[JsObject]), + Json.parse(geojson).as[JsObject], + cooperativeWork.map(Json.parse(_).as[JsObject]), status, mappedOn, completedTimeSpent, @@ -195,7 +190,9 @@ trait TaskParserMixin { responses, bundleId, isBundlePrimary, - errorTags = errorTags.getOrElse("") + errorTags = errorTags.getOrElse(""), + skipCount = skipCount.getOrElse(0), + archived = archived.getOrElse(false) ), TaskReview( -1, diff --git a/app/org/maproulette/framework/model/Challenge.scala b/app/org/maproulette/framework/model/Challenge.scala index d9a51fa9e..7bc8d37a1 100644 --- a/app/org/maproulette/framework/model/Challenge.scala +++ b/app/org/maproulette/framework/model/Challenge.scala @@ -64,14 +64,14 @@ case class PriorityRule(operator: String, key: String, value: String, valueType: private def locationInBounds( operator: String, value: String, - location: Option[String] + location: Option[JsObject] ): Boolean = { // eg. Some({"type":"Point","coordinates":[-120.18699365,48.47991855]}) location match { case Some(loc) => // MinX,MinY,MaxX,MaxY val bbox: List[Double] = Utils.toDoubleList(value).getOrElse(List(0, 0, 0, 0)) - val coordinates = (Json.parse(loc) \ "coordinates").as[List[Double]] + val coordinates = (loc \ "coordinates").as[List[Double]] if (coordinates.length == 2) { val x = coordinates(0) val y = coordinates(1) @@ -138,12 +138,12 @@ case class ChallengeExtra( taskBundleIdProperty: Option[String] = None, isArchived: Boolean = false, reviewSetting: Int = Challenge.REVIEW_SETTING_NOT_REQUIRED, - taskWidgetLayout: Option[JsValue] = None, + taskWidgetLayout: Option[JsObject] = None, datasetUrl: Option[String] = None, systemArchivedAt: Option[DateTime] = None, presets: Option[List[String]] = None, requireConfirmation: Boolean = false, - mrTagMetrics: Option[JsValue] = None + mrTagMetrics: Option[JsObject] = None ) extends DefaultWrites case class ChallengeListing( @@ -156,6 +156,75 @@ case class ChallengeListing( isArchived: Boolean ) +/** + * BaseChallenge is a flattened representation of the Challenge model for API responses. + * All nested fields from ChallengeGeneral, ChallengeCreation, ChallengePriority, and ChallengeExtra + * are exposed at the top level to provide a simpler, more straightforward JSON structure. + */ +case class BaseChallenge( + id: Long, + name: String, + created: DateTime, + modified: DateTime, + description: Option[String] = None, + deleted: Boolean = false, + isGlobal: Boolean = false, + requireConfirmation: Boolean = false, + requireRejectReason: Boolean = false, + infoLink: Option[String] = None, + // Fields from ChallengeGeneral + owner: Long, + parent: Long, + instruction: String, + difficulty: Int = Challenge.DIFFICULTY_NORMAL, + blurb: Option[String] = None, + enabled: Boolean = false, + featured: Boolean = false, + cooperativeType: Int = 0, + popularity: Option[Int] = None, + checkinComment: String = "", + checkinSource: String = "", + requiresLocal: Boolean = false, + // Fields from ChallengeCreation + overpassQL: Option[String] = None, + remoteGeoJson: Option[String] = None, + overpassTargetType: Option[String] = None, + // Fields from ChallengePriority + defaultPriority: Int = Challenge.PRIORITY_HIGH, + highPriorityRule: Option[JsObject] = None, + mediumPriorityRule: Option[JsObject] = None, + lowPriorityRule: Option[JsObject] = None, + highPriorityBounds: Option[JsArray] = None, + mediumPriorityBounds: Option[JsArray] = None, + lowPriorityBounds: Option[JsArray] = None, + // Fields from ChallengeExtra + defaultZoom: Int = Challenge.DEFAULT_ZOOM, + minZoom: Int = Challenge.MIN_ZOOM, + maxZoom: Int = Challenge.MAX_ZOOM, + updateTasks: Boolean = false, + limitTags: Boolean = false, + limitReviewTags: Boolean = false, + isArchived: Boolean = false, + reviewSetting: Int = Challenge.REVIEW_SETTING_NOT_REQUIRED, + defaultBasemap: Option[Int] = None, + defaultBasemapId: Option[String] = None, + customBasemap: Option[String] = None, + exportableProperties: Option[String] = None, + osmIdProperty: Option[String] = None, + taskBundleIdProperty: Option[String] = None, + taskWidgetLayout: Option[JsObject] = None, + taskStyles: Option[JsArray] = None, + // Status and location fields + status: Option[Int] = Some(0), + statusMessage: Option[String] = None, + lastTaskRefresh: Option[DateTime] = None, + dataOriginDate: Option[DateTime] = None, + location: Option[JsObject] = None, + bounding: Option[JsObject] = None, + completionPercentage: Option[Int] = Some(0), + completionMetrics: CompletionMetrics = CompletionMetrics() +) extends DefaultWrites + /** * The ChallengeFormFix case class is built so that we can nest the form objects as there is a limit * on the number of elements allowed in the form mapping. @@ -182,7 +251,7 @@ case class Challenge( location: Option[String] = None, bounding: Option[String] = None, completionPercentage: Option[Int] = Some(0), - tasksRemaining: Option[Int] = Some(0) + completionMetrics: CompletionMetrics = CompletionMetrics() ) extends BaseObject[Long] with DefaultWrites with Identifiable { @@ -259,8 +328,7 @@ case class Challenge( // Extract coordinates from task geometries try { - val geometries = Json.parse(task.geometries) - val features = (geometries \ "features").as[List[JsValue]] + val features = (task.geometries \ "features").as[List[JsValue]] if (features.nonEmpty) { // Check if any feature is within bounds (important for ways/relations with multiple features) features.exists(feature => { @@ -537,6 +605,12 @@ object Challenge extends CommonField { ) } +object BaseChallenge { + import org.maproulette.models.utils.BaseChallengeWrites + val writes = new Object with BaseChallengeWrites + implicit val baseChallengeWrites: Writes[BaseChallenge] = writes.baseChallengeWrites +} + case class ArchivableChallenge( val id: Long, val created: DateTime, diff --git a/app/org/maproulette/framework/model/ChallengeLike.scala b/app/org/maproulette/framework/model/ChallengeLike.scala new file mode 100644 index 000000000..2c2c9996c --- /dev/null +++ b/app/org/maproulette/framework/model/ChallengeLike.scala @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + +package org.maproulette.framework.model + +import org.maproulette.framework.psql.CommonField + +/** + * @author mcuthbert + */ +object ChallengeLike extends CommonField { + val TABLE = "challenge_likes" + + val FIELD_USER_ID = "user_id" + val FIELD_CHALLENGE_ID = "challenge_id" +} diff --git a/app/org/maproulette/framework/model/ChallengeTaskMarker.scala b/app/org/maproulette/framework/model/ChallengeTaskMarker.scala new file mode 100644 index 000000000..1f25034ed --- /dev/null +++ b/app/org/maproulette/framework/model/ChallengeTaskMarker.scala @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ +package org.maproulette.framework.model + +import play.api.libs.json.{Json, Reads, Writes} +import org.maproulette.framework.model.TaskMarkerLocation + +case class SingleTaskMarker( + id: Long, + location: TaskMarkerLocation, + status: Int, + priority: Int, + bundleId: Option[Long] = None, + lockedBy: Option[Long] = None +) + +case class OverlapTaskMarker( + location: TaskMarkerLocation, + tasks: List[SingleTaskMarker] +) + +case class ChallengeTaskMarkersResponse( + markers: List[SingleTaskMarker], + overlaps: List[OverlapTaskMarker] +) + +object OverlapTaskMarker { + implicit val overlapTaskMarkerWrites: Writes[OverlapTaskMarker] = + Json.writes[OverlapTaskMarker] + implicit val overlapTaskMarkerReads: Reads[OverlapTaskMarker] = + Json.reads[OverlapTaskMarker] +} + +object SingleTaskMarker { + implicit val singleTaskMarkerWrites: Writes[SingleTaskMarker] = + Json.writes[SingleTaskMarker] + implicit val singleTaskMarkerReads: Reads[SingleTaskMarker] = + Json.reads[SingleTaskMarker] +} + +object ChallengeTaskMarkersResponse { + implicit val challengeTaskMarkersResponseWrites: Writes[ChallengeTaskMarkersResponse] = + Json.writes[ChallengeTaskMarkersResponse] + implicit val challengeTaskMarkersResponseReads: Reads[ChallengeTaskMarkersResponse] = + Json.reads[ChallengeTaskMarkersResponse] +} diff --git a/app/org/maproulette/framework/model/ClusteredPoint.scala b/app/org/maproulette/framework/model/ClusteredPoint.scala index 66399ff28..cad4e1ffa 100644 --- a/app/org/maproulette/framework/model/ClusteredPoint.scala +++ b/app/org/maproulette/framework/model/ClusteredPoint.scala @@ -5,7 +5,7 @@ package org.maproulette.framework.model import org.joda.time.DateTime -import play.api.libs.json.{JsValue, Json, Reads, Writes} +import play.api.libs.json.{JsObject, Json, Reads, Writes} import play.api.libs.json.JodaWrites._ import play.api.libs.json.JodaReads._ @@ -56,7 +56,7 @@ case class ClusteredPoint( parentId: Long, parentName: String, point: Point, - bounding: JsValue, + bounding: JsObject, blurb: String, modified: DateTime, difficulty: Int, diff --git a/app/org/maproulette/framework/model/CompletionMetrics.scala b/app/org/maproulette/framework/model/CompletionMetrics.scala new file mode 100644 index 000000000..a61bcf3a8 --- /dev/null +++ b/app/org/maproulette/framework/model/CompletionMetrics.scala @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ +package org.maproulette.framework.model + +import play.api.libs.json.{Json, Reads, Writes} + +/** + * Per-status task counts for a challenge or project. Stored directly on the + * owning row so the counts are available without a separate stats call. + */ +case class CompletionMetrics( + total: Int = 0, + available: Int = 0, + fixed: Int = 0, + falsePositive: Int = 0, + skipped: Int = 0, + deleted: Int = 0, + alreadyFixed: Int = 0, + tooHard: Int = 0, + answered: Int = 0, + validated: Int = 0, + disabled: Int = 0, + // Derived: tasks still needing work. Always recomputed on read so the + // persisted value cannot drift from the per-status counts. + tasksRemaining: Int = 0 +) + +object CompletionMetrics { + implicit val writes: Writes[CompletionMetrics] = Json.writes[CompletionMetrics] + implicit val reads: Reads[CompletionMetrics] = + Json.using[Json.WithDefaultValues].reads[CompletionMetrics].map { m => + m.copy(tasksRemaining = m.available + m.skipped + m.tooHard) + } +} diff --git a/app/org/maproulette/framework/model/Project.scala b/app/org/maproulette/framework/model/Project.scala index 85926d66f..d1e056b78 100644 --- a/app/org/maproulette/framework/model/Project.scala +++ b/app/org/maproulette/framework/model/Project.scala @@ -34,7 +34,8 @@ case class Project( isVirtual: Option[Boolean] = Some(false), featured: Boolean = false, isArchived: Boolean = false, - requireConfirmation: Boolean = false + requireConfirmation: Boolean = false, + completionMetrics: CompletionMetrics = CompletionMetrics() ) extends CacheObject[Long] with Identifiable { def grantsToType(granteeType: ItemType) = @@ -45,7 +46,7 @@ object Project extends CommonField { implicit val grantWrites: Writes[Grant] = Grant.writes implicit val grantReads: Reads[Grant] = Grant.reads implicit val writes: Writes[Project] = Json.writes[Project] - implicit val reads: Reads[Project] = Json.reads[Project] + implicit val reads: Reads[Project] = Json.using[Json.WithDefaultValues].reads[Project] val TABLE = "projects" val KEY_GRANTS = "grants" diff --git a/app/org/maproulette/framework/model/Task.scala b/app/org/maproulette/framework/model/Task.scala index 081d2b6e8..f114b348c 100644 --- a/app/org/maproulette/framework/model/Task.scala +++ b/app/org/maproulette/framework/model/Task.scala @@ -4,7 +4,6 @@ */ package org.maproulette.framework.model -import org.apache.commons.lang3.StringUtils import org.joda.time.DateTime import org.maproulette.data.{ItemType, TaskType} import org.maproulette.framework.model.{Challenge, Identifiable, MapillaryImage} @@ -57,9 +56,9 @@ case class Task( override val modified: DateTime, parent: Long, instruction: Option[String] = None, - location: Option[String] = None, - geometries: String, - cooperativeWork: Option[String] = None, + location: Option[JsObject] = None, + geometries: JsObject, + cooperativeWork: Option[JsObject] = None, status: Option[Int] = None, mappedOn: Option[DateTime] = None, completedTimeSpent: Option[Long] = None, @@ -71,7 +70,9 @@ case class Task( bundleId: Option[Long] = None, isBundlePrimary: Option[Boolean] = None, mapillaryImages: Option[List[MapillaryImage]] = None, - errorTags: String = "" + errorTags: String = "", + skipCount: Int = 0, + archived: Boolean = false ) extends BaseObject[Long] with DefaultReads with LowPriorityDefaultReads @@ -110,13 +111,11 @@ case class Task( } def getGeometryProperties(): List[Map[String, String]] = { - if (StringUtils.isNotEmpty(this.geometries)) { - val geojson = Json.parse(this.geometries) - (geojson \ "features") - .as[List[JsValue]] - .map(json => Utils.getProperties(json, "properties").as[Map[String, String]]) - } else { - List.empty + (this.geometries \ "features").asOpt[List[JsValue]] match { + case Some(features) => + features.map(json => Utils.getProperties(json, "properties").as[Map[String, String]]) + case None => + List.empty } } } @@ -131,29 +130,108 @@ object Task extends CommonField { val FIELD_BUNDLE_PRIMARY = "is_bundle_primary" val FIELD_MAPPED_ON = "mapped_on" + // `Task` has 23 fields, which exceeds Scala 2's 22-element tuple/unapply cap, + // so `Json.writes[Task]` / `Json.reads[Task]` macros can't be used. The manual + // Writes/Reads below produce the same JSON shape the macros would. + private def taskObjectWrites( + o: Task + )( + implicit mapillaryWrites: Writes[MapillaryImage], + reviewWrites: Writes[TaskReviewFields] + ): JsObject = + Json.obj( + "id" -> o.id, + "name" -> o.name, + "created" -> o.created, + "modified" -> o.modified, + "parent" -> o.parent, + "instruction" -> o.instruction, + "location" -> o.location, + "geometries" -> o.geometries, + "cooperativeWork" -> o.cooperativeWork, + "status" -> o.status, + "mappedOn" -> o.mappedOn, + "completedTimeSpent" -> o.completedTimeSpent, + "completedBy" -> o.completedBy, + "review" -> o.review, + "priority" -> o.priority, + "changesetId" -> o.changesetId, + "completionResponses" -> o.completionResponses, + "bundleId" -> o.bundleId, + "isBundlePrimary" -> o.isBundlePrimary, + "mapillaryImages" -> o.mapillaryImages, + "errorTags" -> o.errorTags, + "skipCount" -> o.skipCount, + "archived" -> o.archived + ) + + private def taskObjectReads( + json: JsValue + )( + implicit mapillaryReads: Reads[MapillaryImage], + reviewReads: Reads[TaskReviewFields] + ): JsResult[Task] = + for { + id <- (json \ "id").validate[Long] + name <- (json \ "name").validate[String] + created <- (json \ "created").validate[DateTime] + modified <- (json \ "modified").validate[DateTime] + parent <- (json \ "parent").validate[Long] + instruction <- (json \ "instruction").validateOpt[String] + location <- (json \ "location").validateOpt[JsObject] + geometries <- (json \ "geometries").validate[JsObject] + cooperativeWork <- (json \ "cooperativeWork").validateOpt[JsObject] + status <- (json \ "status").validateOpt[Int] + mappedOn <- (json \ "mappedOn").validateOpt[DateTime] + completedTimeSpent <- (json \ "completedTimeSpent").validateOpt[Long] + completedBy <- (json \ "completedBy").validateOpt[Long] + review <- (json \ "review").validate[TaskReviewFields] + priority <- (json \ "priority").validate[Int] + changesetId <- (json \ "changesetId").validateOpt[Long] + completionResponses <- (json \ "completionResponses").validateOpt[String] + bundleId <- (json \ "bundleId").validateOpt[Long] + isBundlePrimary <- (json \ "isBundlePrimary").validateOpt[Boolean] + mapillaryImages <- (json \ "mapillaryImages").validateOpt[List[MapillaryImage]] + errorTags <- (json \ "errorTags").validate[String] + skipCount <- (json \ "skipCount").validate[Int] + archived <- (json \ "archived").validate[Boolean] + } yield Task( + id, + name, + created, + modified, + parent, + instruction, + location, + geometries, + cooperativeWork, + status, + mappedOn, + completedTimeSpent, + completedBy, + review, + priority, + changesetId, + completionResponses, + bundleId, + isBundlePrimary, + mapillaryImages, + errorTags, + skipCount, + archived + ) + implicit object TaskFormat extends Format[Task] { override def writes(o: Task): JsValue = { implicit val mapillaryWrites: Writes[MapillaryImage] = Json.writes[MapillaryImage] implicit val reviewWrites: Writes[TaskReviewFields] = Json.writes[TaskReviewFields] - implicit val taskWrites: Writes[Task] = Json.writes[Task] - var original = Json.toJson(o)(Json.writes[Task]) - var updatedLocation = o.location match { - case Some(l) => Utils.insertIntoJson(original, "location", Json.parse(l), true) - case None => original - } - - original = Utils.insertIntoJson(updatedLocation, "geometries", Json.parse(o.geometries), true) - var updated = o.cooperativeWork match { - case Some(cw) => Utils.insertIntoJson(original, "cooperativeWork", Json.parse(cw), true) - case None => original - } + implicit val taskWrites: Writes[Task] = Writes(taskObjectWrites) + var updated = Json.toJson(o)(taskWrites) // Move review fields up to top level updated = o.review.reviewStatus match { - case Some(r) => { - Utils.insertIntoJson(updated, "reviewStatus", r, true) - } - case None => updated + case Some(r) => Utils.insertIntoJson(updated, "reviewStatus", r, true) + case None => updated } updated = o.review.reviewRequestedBy match { case Some(r) => Utils.insertIntoJson(updated, "reviewRequestedBy", r, true) @@ -168,10 +246,8 @@ object Task extends CommonField { case None => updated } updated = o.review.metaReviewStatus match { - case Some(r) => { - Utils.insertIntoJson(updated, "metaReviewStatus", r, true) - } - case None => updated + case Some(r) => Utils.insertIntoJson(updated, "metaReviewStatus", r, true) + case None => updated } updated = o.review.metaReviewedBy match { case Some(r) => Utils.insertIntoJson(updated, "metaReviewedBy", r, true) @@ -194,7 +270,7 @@ object Task extends CommonField { case None => updated } - Utils.insertIntoJson(updated, "geometries", Json.parse(o.geometries), true) + updated } override def reads(json: JsValue): JsResult[Task] = { @@ -202,7 +278,13 @@ object Task extends CommonField { implicit val reviewReads: Reads[TaskReviewFields] = Json.reads[TaskReviewFields] val jsonWithReview = Utils.insertIntoJson(json, "review", Map[String, String](), false) - Json.fromJson[Task](jsonWithReview)(Json.reads[Task]) + // `skipCount` and `archived` are server-managed columns that default to 0 / false + // on first insert. Insert the defaults here so older clients (and existing + // POST bodies) that don't supply these fields still parse cleanly. + val withSkipDefault = Utils.insertIntoJson(jsonWithReview, "skipCount", 0, false) + val withArchivedDefault = + Utils.insertIntoJson(withSkipDefault, "archived", false, false) + taskObjectReads(withArchivedDefault) } } @@ -386,5 +468,5 @@ object Task extends CommonField { } def emptyTask(parentId: Long): Task = - Task(-1, "", DateTime.now(), DateTime.now(), parentId, Some(""), None, "") + Task(-1, "", DateTime.now(), DateTime.now(), parentId, Some(""), None, Json.obj()) } diff --git a/app/org/maproulette/framework/model/TaskCluster.scala b/app/org/maproulette/framework/model/TaskCluster.scala index d4f7b9d14..40477d376 100644 --- a/app/org/maproulette/framework/model/TaskCluster.scala +++ b/app/org/maproulette/framework/model/TaskCluster.scala @@ -20,9 +20,9 @@ case class TaskCluster( taskPriority: Option[Int], params: SearchParameters, point: Point, - bounding: JsValue = Json.toJson("{}"), + bounding: JsObject = Json.obj(), challengeIds: List[Long], - geometries: Option[JsValue] = None + geometries: Option[JsObject] = None ) extends DefaultWrites object TaskCluster { diff --git a/app/org/maproulette/framework/model/TaskClusterSummary.scala b/app/org/maproulette/framework/model/TaskClusterSummary.scala new file mode 100644 index 000000000..1c090d66b --- /dev/null +++ b/app/org/maproulette/framework/model/TaskClusterSummary.scala @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ +package org.maproulette.framework.model + +import play.api.libs.json._ + +/** + * Simplified task cluster object for lightweight cluster queries + * + * @param clusterId The cluster identifier + * @param numberOfPoints The number of tasks in this cluster + * @param taskId Optional task ID (only present when cluster has exactly 1 task) + * @param taskStatus Optional task status (only present when cluster has exactly 1 task) + * @param taskPriority Optional task priority (only present when cluster has exactly 1 task) + * @param point The centroid point of the cluster + * @param bounding The bounding geometry of the cluster + */ +case class TaskClusterSummary( + clusterId: Int, + numberOfPoints: Int, + taskId: Option[Long], + taskStatus: Option[Int], + point: Point, + bounding: JsObject = Json.obj() +) extends DefaultWrites + +object TaskClusterSummary { + implicit val pointWrites: Writes[Point] = Json.writes[Point] + implicit val pointReads: Reads[Point] = Json.reads[Point] + implicit val taskClusterSummaryWrites: Writes[TaskClusterSummary] = + Json.writes[TaskClusterSummary] + implicit val taskClusterSummaryReads: Reads[TaskClusterSummary] = Json.reads[TaskClusterSummary] +} diff --git a/app/org/maproulette/framework/model/TaskMarker.scala b/app/org/maproulette/framework/model/TaskMarker.scala new file mode 100644 index 000000000..7aa42481b --- /dev/null +++ b/app/org/maproulette/framework/model/TaskMarker.scala @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ +package org.maproulette.framework.model + +import play.api.libs.json.{Json, Reads, Writes} + +/** + * @author cuthbertm + */ +case class TaskMarkerLocation(lat: Double, lng: Double) + +object TaskMarkerLocation { + implicit val taskMarkerLocationWrites: Writes[TaskMarkerLocation] = + Json.writes[TaskMarkerLocation] + implicit val taskMarkerLocationReads: Reads[TaskMarkerLocation] = Json.reads[TaskMarkerLocation] +} + +/** + * A lightweight task marker model containing only essential data for map display + * + * @param id The id of the task + * @param location The latitude and longitude of the task + * @param status The status of the task + * @param priority The priority level of the task (1=Easy, 2=Normal, 3=Expert) + * @param bundleId Optional bundle id if the task is part of a bundle + * @param lockedBy Optional user id if the task is locked by a user + */ +case class TaskMarker( + id: Long, + location: TaskMarkerLocation, + status: Int, + priority: Int, + bundleId: Option[Long] = None, + lockedBy: Option[Long] = None +) + +object TaskMarker { + implicit val taskMarkerWrites: Writes[TaskMarker] = Json.writes[TaskMarker] + implicit val taskMarkerReads: Reads[TaskMarker] = Json.reads[TaskMarker] +} diff --git a/app/org/maproulette/framework/model/TaskMarkerResponse.scala b/app/org/maproulette/framework/model/TaskMarkerResponse.scala new file mode 100644 index 000000000..3326f83f7 --- /dev/null +++ b/app/org/maproulette/framework/model/TaskMarkerResponse.scala @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ +package org.maproulette.framework.model + +import play.api.libs.json._ + +/** + * Response wrapper for task marker queries that includes total count + * and either individual markers, clusters, or nothing if there are too many tasks + * + * @param totalCount The total number of tasks matching the query + * @param tasks Optional list of individual task markers (non-overlapping) + * @param overlappingTasks Optional list of overlapping task markers + * @param clusters Optional list of task cluster summaries + */ +case class TaskMarkerResponse( + totalCount: Int, + tasks: Option[List[TaskMarker]] = None, + overlappingTasks: Option[List[OverlappingTaskMarker]] = None, + clusters: Option[List[TaskClusterSummary]] = None +) + +/** + * Represents a group of tasks that share the same location + * + * @param location The shared location of all tasks in this overlap + * @param tasks List of task markers at this location + */ +case class OverlappingTaskMarker( + location: TaskMarkerLocation, + tasks: List[TaskMarker] +) + +object OverlappingTaskMarker { + implicit val overlappingTaskMarkerWrites: Writes[OverlappingTaskMarker] = + Json.writes[OverlappingTaskMarker] + implicit val overlappingTaskMarkerReads: Reads[OverlappingTaskMarker] = + Json.reads[OverlappingTaskMarker] +} + +object TaskMarkerResponse { + implicit val taskMarkerResponseWrites: Writes[TaskMarkerResponse] = + Json.writes[TaskMarkerResponse] + implicit val taskMarkerResponseReads: Reads[TaskMarkerResponse] = Json.reads[TaskMarkerResponse] +} diff --git a/app/org/maproulette/framework/model/User.scala b/app/org/maproulette/framework/model/User.scala index b4c9842c7..3a8088f32 100644 --- a/app/org/maproulette/framework/model/User.scala +++ b/app/org/maproulette/framework/model/User.scala @@ -12,7 +12,7 @@ import org.joda.time.format.DateTimeFormat import org.maproulette.Config import org.maproulette.cache.CacheObject import org.maproulette.framework.psql.CommonField -import org.maproulette.utils.{Crypto, Utils} +import org.maproulette.utils.Crypto import org.maproulette.data._ import org.slf4j.LoggerFactory import play.api.libs.json._ @@ -165,6 +165,7 @@ object Follower { * 6=skin-purple, 7=skin-purple-light, 8=skin-red, 9=skin-red-light, 10=skin-yellow, 11=skin-yellow-light * @param customBasemaps Custom basemaps defined by user, which basemap to show is the name selected by the defaultBasemap. * @param showPriorityMarkerColors If true, display task priority as colored outlines on map markers (high=red, medium=orange, low=teal) + * @param plugins User's installed plugins configuration stored as JSON string */ case class UserSettings( defaultEditor: Option[Int] = None, @@ -181,7 +182,8 @@ case class UserSettings( customBasemaps: Option[List[CustomBasemap]] = None, seeTagFixSuggestions: Option[Boolean] = None, disableTaskConfirm: Option[Boolean] = None, - showPriorityMarkerColors: Option[Boolean] = None + showPriorityMarkerColors: Option[Boolean] = None, + plugins: Option[String] = None ) { def getTheme: String = theme match { case Some(t) => @@ -224,7 +226,7 @@ case class User( apiKey: Option[String] = None, guest: Boolean = false, settings: UserSettings = UserSettings(), - properties: Option[String] = None, + properties: Option[JsObject] = None, score: Option[Int] = None, followingGroupId: Option[Long] = None, followersGroupId: Option[Long] = None, @@ -299,30 +301,9 @@ object User extends CommonField { val FIELD_NEEDS_REVIEW = "needs_review" val FIELD_IS_REVIEWER = "is_reviewer" - implicit object UserFormat extends Format[User] { - override def writes(o: User): JsValue = { - implicit val taskWrites: Writes[User] = Json.writes[User] - val original = Json.toJson(o)(Json.writes[User]) - val updated = o.properties match { - case Some(p) => Utils.insertIntoJson(original, "properties", Json.parse(p), true) - case None => original - } - Utils.insertIntoJson(updated, "properties", Json.parse(o.properties.getOrElse("{}")), true) - } - - override def reads(json: JsValue): JsResult[User] = { - val modifiedJson: JsValue = (json \ "properties").toOption match { - case Some(p) => - p match { - case props: JsString => json - case _ => - json.as[JsObject] ++ Json.obj("properties" -> p.toString()) - } - case None => json - } - Json.fromJson[User](modifiedJson)(Json.reads[User]) - } - } + implicit val userWrites: Writes[User] = Json.writes[User] + implicit val userReads: Reads[User] = Json.reads[User] + implicit val UserFormat: Format[User] = Format(userReads, userWrites) val DEFAULT_GUEST_USER_ID = -998 val DEFAULT_SUPER_USER_ID = -999 diff --git a/app/org/maproulette/framework/repository/ChallengeCommentRepository.scala b/app/org/maproulette/framework/repository/ChallengeCommentRepository.scala index 34b8ab1f6..dafe90e70 100644 --- a/app/org/maproulette/framework/repository/ChallengeCommentRepository.scala +++ b/app/org/maproulette/framework/repository/ChallengeCommentRepository.scala @@ -98,6 +98,60 @@ class ChallengeCommentRepository @Inject() (override val db: Database) extends R } } + /** + * Searches challenge comments by a search term, scoped to comments the + * requesting user authored or comments on challenges the user owns. Callers + * must supply the authenticated user's ids; there is no unscoped variant. + * + * @param searchTerm The term to search within the comments + * @param userId The requesting user's internal id (matches challenges.owner_id) + * @param userOsmId The requesting user's OSM id (matches challenge_comments.osm_id) + * @param limit The maximum number of comments to return + * @param page The page number for pagination + * @return A list of matching ChallengeComments + */ + def searchComments( + searchTerm: String, + userId: Long, + userOsmId: Long, + limit: Int = 25, + page: Int = 0 + )(implicit c: Option[Connection] = None): List[ChallengeComment] = { + withMRConnection { implicit c => + val searchFilter = + if (searchTerm.nonEmpty) "AND c.comment ILIKE {searchTerm}" + else "" + val query = + s""" + SELECT c.id, c.project_id, c.challenge_id, c.created, c.comment, c.osm_id, + u.name, u.avatar_url + FROM challenge_comments c + INNER JOIN users AS u ON c.osm_id = u.osm_id + WHERE ( + c.osm_id = {userOsmId} + OR EXISTS ( + SELECT 1 FROM challenges ch + WHERE ch.id = c.challenge_id AND ch.owner_id = {userId} + ) + ) + $searchFilter + ORDER BY c.created DESC + LIMIT {limit} + OFFSET {offset} + """ + + SQL(query) + .on( + "searchTerm" -> s"%$searchTerm%", + "userId" -> userId, + "userOsmId" -> userOsmId, + "limit" -> limit, + "offset" -> (limit * page).toLong + ) + .as(ChallengeCommentRepository.parser.*) + } + } + /** * Add comment to a challenge * diff --git a/app/org/maproulette/framework/repository/ChallengeRepository.scala b/app/org/maproulette/framework/repository/ChallengeRepository.scala index 948702aa4..144313bb1 100644 --- a/app/org/maproulette/framework/repository/ChallengeRepository.scala +++ b/app/org/maproulette/framework/repository/ChallengeRepository.scala @@ -16,7 +16,7 @@ import org.maproulette.framework.model._ import org.maproulette.framework.psql.{GroupField, Grouping, OR, Query} import org.maproulette.framework.psql.filter._ import play.api.db.Database -import play.api.libs.json.JsValue +import play.api.libs.json.{JsObject, JsValue} /** * The challenge repository handles all the querying with the databases related to challenge objects @@ -351,7 +351,7 @@ object ChallengeRepository { taskBundleIdProperty, isArchived, reviewSetting, - taskWidgetLayout, + taskWidgetLayout.map(_.as[JsObject]), datasetUrl, systemArchivedAt ), diff --git a/app/org/maproulette/framework/repository/CommentRepository.scala b/app/org/maproulette/framework/repository/CommentRepository.scala index 51126b17c..9ea0095ab 100644 --- a/app/org/maproulette/framework/repository/CommentRepository.scala +++ b/app/org/maproulette/framework/repository/CommentRepository.scala @@ -102,6 +102,61 @@ class CommentRepository @Inject() (override val db: Database) extends Repository } } + /** + * Searches task comments by a search term, scoped to comments the requesting + * user authored or comments on challenges the user owns. Callers must supply + * the authenticated user's ids; there is no unscoped variant. + * + * @param searchTerm The term to search within the comments + * @param userId The requesting user's internal id (matches challenges.owner_id) + * @param userOsmId The requesting user's OSM id (matches task_comments.osm_id) + * @param limit The maximum number of comments to return + * @param page The page number for pagination + * @return A list of matching Comments + */ + def searchComments( + searchTerm: String, + userId: Long, + userOsmId: Long, + limit: Int = 25, + page: Int = 0 + )(implicit c: Option[Connection] = None): List[Comment] = { + withMRConnection { implicit c => + val searchFilter = + if (searchTerm.nonEmpty) "AND task_comments.comment ILIKE {searchTerm}" + else "" + val query = + s""" + SELECT task_comments.id, task_comments.osm_id, users.name, users.avatar_url, + task_comments.task_id, task_comments.challenge_id, task_comments.project_id, + task_comments.created, task_comments.comment, task_comments.action_id, task_comments.edited + FROM task_comments + INNER JOIN users ON users.osm_id = task_comments.osm_id + WHERE ( + task_comments.osm_id = {userOsmId} + OR EXISTS ( + SELECT 1 FROM challenges ch + WHERE ch.id = task_comments.challenge_id AND ch.owner_id = {userId} + ) + ) + $searchFilter + ORDER BY task_comments.created DESC + LIMIT {limit} + OFFSET {offset} + """ + + SQL(query) + .on( + "searchTerm" -> s"%$searchTerm%", + "userId" -> userId, + "userOsmId" -> userOsmId, + "limit" -> limit, + "offset" -> (limit * page).toLong + ) + .as(CommentRepository.parser.*) + } + } + /** * Add comment to a task * diff --git a/app/org/maproulette/framework/repository/ProjectRepository.scala b/app/org/maproulette/framework/repository/ProjectRepository.scala index 0328f0082..76533b5a6 100644 --- a/app/org/maproulette/framework/repository/ProjectRepository.scala +++ b/app/org/maproulette/framework/repository/ProjectRepository.scala @@ -9,6 +9,7 @@ import java.sql.Connection import anorm.SqlParser._ import anorm._ +import anorm.postgresql.jsValueColumn import javax.inject.{Inject, Singleton} import org.joda.time.DateTime import org.maproulette.data.Actions @@ -19,7 +20,7 @@ import org.maproulette.framework.service.GrantService import org.maproulette.session.SearchParameters import org.maproulette.utils.Readers import play.api.db.Database -import play.api.libs.json.Json +import play.api.libs.json.{JsObject, Json} /** * Repository to handle all database actionns related to Projects, no business logic should be @@ -363,7 +364,7 @@ object ProjectRepository extends Readers { val coordinates = (locationJSON \ "coordinates").as[List[Double]] val point = Point(coordinates(1), coordinates.head) val pointReview = PointReview(None, None, None, None, None, None, None, None, None) - val boundingJSON = Json.parse(bounding) + val boundingJSON = Json.parse(bounding).as[JsObject] ClusteredPoint( id, osm_id, @@ -405,8 +406,10 @@ object ProjectRepository extends Readers { get[Boolean]("projects.is_virtual") ~ get[Boolean]("projects.featured") ~ get[Boolean]("projects.is_archived") ~ - get[Boolean]("projects.require_confirmation") map { - case id ~ ownerId ~ name ~ created ~ modified ~ description ~ enabled ~ displayName ~ deleted ~ isVirtual ~ featured ~ isArchived ~ requireConfirmation => + get[Boolean]("projects.require_confirmation") ~ + get[Option[play.api.libs.json.JsValue]]("projects.completion_metrics") map { + case id ~ ownerId ~ name ~ created ~ modified ~ description ~ enabled ~ displayName ~ deleted ~ + isVirtual ~ featured ~ isArchived ~ requireConfirmation ~ completionMetricsJson => new Project( id, ownerId, @@ -421,7 +424,10 @@ object ProjectRepository extends Readers { Some(isVirtual), featured, isArchived, - requireConfirmation + requireConfirmation, + completionMetricsJson + .flatMap(_.asOpt[CompletionMetrics]) + .getOrElse(CompletionMetrics()) ) } } diff --git a/app/org/maproulette/framework/repository/TaskBundleRepository.scala b/app/org/maproulette/framework/repository/TaskBundleRepository.scala index b845a89fc..dbd8ed9ab 100644 --- a/app/org/maproulette/framework/repository/TaskBundleRepository.scala +++ b/app/org/maproulette/framework/repository/TaskBundleRepository.scala @@ -313,7 +313,7 @@ class TaskBundleRepository @Inject() ( query.build(s"""SELECT ${this.retrieveColumnsWithReview} FROM ${baseTable} INNER JOIN task_bundles tb on tasks.id = tb.task_id LEFT OUTER JOIN task_review ON task_review.task_id = tasks.id - """).as(this.getTaskParser(this.taskRepository.updateAndRetrieve).*) + """).as(this.getTaskParser().*) } } diff --git a/app/org/maproulette/framework/repository/TaskClusterRepository.scala b/app/org/maproulette/framework/repository/TaskClusterRepository.scala index 5efa3b47d..8ad9dd10b 100644 --- a/app/org/maproulette/framework/repository/TaskClusterRepository.scala +++ b/app/org/maproulette/framework/repository/TaskClusterRepository.scala @@ -11,17 +11,27 @@ import anorm.~ import anorm._ import anorm.SqlParser.{get, int, str} import javax.inject.{Inject, Singleton} -import org.maproulette.session.SearchParameters +import org.maproulette.session.{SearchParameters, SearchLocation} import org.maproulette.framework.psql.{Query, Order, Paging} -import org.maproulette.framework.model.{ClusteredPoint, Point, TaskCluster} +import org.maproulette.framework.model.{ + ClusteredPoint, + OverlappingTaskMarker, + Point, + TaskCluster, + TaskClusterSummary, + TaskMarker, + TaskMarkerLocation +} import play.api.db.Database import play.api.libs.json._ import org.maproulette.models.dal.ChallengeDAL @Singleton -class TaskClusterRepository @Inject() (override val db: Database, challengeDAL: ChallengeDAL) - extends RepositoryMixin { +class TaskClusterRepository @Inject() ( + override val db: Database, + challengeDAL: ChallengeDAL +) extends RepositoryMixin { implicit val baseTable: String = "tasks" val DEFAULT_NUMBER_OF_POINTS = 100 @@ -202,6 +212,83 @@ class TaskClusterRepository @Inject() (override val db: Database, challengeDAL: } } + /** + * Simple query for challenge tasks in a bounding box with pagination + * + * @param bounds The bounding box to search within + * @param challengeIds List of challenge IDs to filter by (optional) + * @param limit Maximum number of results per page + * @param offset Offset for pagination + * @return Tuple of (total count, list of tasks) + */ + def queryChallengeTasksInBounds( + bounds: Option[SearchLocation], + challengeIds: Option[List[Long]], + limit: Int, + offset: Int + ): (Int, List[ClusteredPoint]) = { + this.withMRTransaction { implicit c => + var whereConditions = List( + "c.deleted = false", + "c.enabled = true", + "c.is_archived = false", + "tasks.location IS NOT NULL" + ) + + // Add bounding box filter + bounds.foreach { b => + whereConditions = whereConditions :+ + s"ST_Intersects(tasks.location, ST_MakeEnvelope(${b.left}, ${b.bottom}, ${b.right}, ${b.top}, 4326))" + } + + // Add challenge ID filter + challengeIds.foreach { ids => + if (ids.nonEmpty) { + whereConditions = whereConditions :+ s"c.id IN (${ids.mkString(",")})" + } + } + + val whereClause = whereConditions.mkString(" AND ") + + // Count query + val countQuery = s""" + SELECT COUNT(DISTINCT tasks.id) as count + FROM tasks + INNER JOIN challenges c ON c.id = tasks.parent_id + WHERE $whereClause + """ + val count = SQL(countQuery).as(int("count").single) + + // Data query with pagination + val dataQuery = s""" + SELECT tasks.id, tasks.name, tasks.parent_id, c.name, tasks.instruction, tasks.status, + tasks.mapped_on, tasks.completed_time_spent, tasks.completed_by, + tasks.bundle_id, tasks.is_bundle_primary, tasks.cooperative_work_json::TEXT as cooperative_work, + NULL::INTEGER as "task_review.review_status", + NULL::BIGINT as "task_review.review_requested_by", + NULL::BIGINT as "task_review.reviewed_by", + NULL::TIMESTAMP as "task_review.reviewed_at", + NULL::TIMESTAMP as "task_review.review_started_at", + NULL::BIGINT[] as "task_review.additional_reviewers", + NULL::INTEGER as "task_review.meta_review_status", + NULL::BIGINT as "task_review.meta_reviewed_by", + NULL::TIMESTAMP as "task_review.meta_reviewed_at", + ST_AsGeoJSON(tasks.location) AS location, + tasks.priority, + l.user_id as locked_by + FROM tasks + INNER JOIN challenges c ON c.id = tasks.parent_id + LEFT JOIN locked l ON l.item_id = tasks.id AND l.item_type = 2 + WHERE $whereClause + ORDER BY tasks.id + LIMIT $limit OFFSET $offset + """ + val results = SQL(dataQuery).as(this.pointParser.*) + + (count, results) + } + } + private def getTaskClusterParser(params: SearchParameters): anorm.RowParser[Serializable] = { int("kmeans") ~ int("numberOfPoints") ~ get[Option[Long]]("taskId") ~ get[Option[Int]]("taskStatus") ~ get[Option[Int]]("taskPriority") ~ get[Option[String]]( @@ -222,13 +309,432 @@ class TaskClusterRepository @Inject() (override val db: Database, challengeDAL: taskPriority, params, point, - Json.parse(bounding), + Json.parse(bounding).as[JsObject], challengeIds, - geojson.map(Json.parse(_)) + geojson.map(Json.parse(_).as[JsObject]) + ) + } else { + None + } + } + } + + private def getTaskClusterSummaryParser(): anorm.RowParser[Serializable] = { + int("kmeans") ~ int("numberOfPoints") ~ get[Option[Long]]("taskId") ~ + get[Option[Int]]("taskStatus") ~ + str("geom") ~ str("bounding") map { + case kmeans ~ totalPoints ~ taskId ~ taskStatus ~ geom ~ bounding => + val locationJSON = Json.parse(geom) + val coordinates = (locationJSON \ "coordinates").as[List[Double]] + // Let's check to make sure we received valid number of coordinates. + if (coordinates.length > 1) { + val point = Point(coordinates(1), coordinates.head) + TaskClusterSummary( + kmeans, + totalPoints, + taskId, + taskStatus, + point, + Json.parse(bounding).as[JsObject] ) } else { None } } } + + /** + * Queries task markers + * + * @param statuses List of task status filters + * @param global Whether to include global challenges + * @return List of task markers + */ + def queryTaskMarkers( + statuses: List[Int], + global: Boolean + ): List[TaskMarker] = { + // Use a global bounding box covering the entire world + val worldBoundingBox = SearchLocation(-180.0, -90.0, 180.0, 90.0) + this.queryTaskMarkersWithBoundingBox(statuses, global, worldBoundingBox) + } + + def queryTaskMarkersClustered( + statuses: List[Int], + global: Boolean, + boundingBox: SearchLocation, + keywords: Option[String] = None, + difficulty: Option[Int] = None + ): List[TaskClusterSummary] = { + this.withMRTransaction { implicit c => + val left = boundingBox.left + val bottom = boundingBox.bottom + val right = boundingBox.right + val top = boundingBox.top + val statusList = statuses.mkString(",") + + val keywordList = parseKeywords(keywords) + val keywordParams = keywordNamedParameters(keywordList) + + // Build joins for keywords filtering if keywords are provided + val joins = if (keywordList.nonEmpty) { + " INNER JOIN tags_on_challenges toc ON c.id = toc.challenge_id" + + " INNER JOIN tags tags_table ON toc.tag_id = tags_table.id" + } else "" + + val query = s""" +WITH eligible_challenges AS MATERIALIZED ( + SELECT c.id + FROM challenges c + INNER JOIN projects p ON p.id = c.parent_id + ${joins} + WHERE c.deleted = false + AND c.enabled = true + AND c.is_archived = false + AND p.deleted = false + AND p.enabled = true + ${if (!global) "AND c.is_global = false" else ""} + ${difficulty.map(d => s"AND c.difficulty = $d").getOrElse("")} + ${keywordInClause("tags_table.name", keywordList)} +), +filtered_tasks AS MATERIALIZED ( + SELECT DISTINCT + t.id AS taskId, + t.status AS taskStatus, + t.location AS taskLocation + FROM tasks t + WHERE t.parent_id IN (SELECT id FROM eligible_challenges) + AND t.location && ST_MakeEnvelope($left, $bottom, $right, $top, 4326) + ${if (statuses.nonEmpty) s"AND t.status IN ($statusList)" else ""} + AND t.location IS NOT NULL +), +cluster_input AS ( + SELECT + *, + LEAST(COUNT(*) OVER (), 50) AS cluster_count + FROM filtered_tasks +), +task_clusters AS ( + SELECT + *, + ST_ClusterKMeans(taskLocation, cluster_count::int) OVER () AS kmeans + FROM cluster_input +) +SELECT + kmeans, + COUNT(*) AS numberOfPoints, + CASE WHEN COUNT(*) = 1 THEN (ARRAY_AGG(taskId))[1] END AS taskId, + CASE WHEN COUNT(*) = 1 THEN (ARRAY_AGG(taskStatus))[1] END AS taskStatus, + ST_AsGeoJSON(ST_Centroid(ST_Collect(taskLocation))) AS geom, + ST_AsGeoJSON(ST_ConvexHull(ST_Collect(taskLocation))) AS bounding +FROM task_clusters +GROUP BY kmeans +ORDER BY kmeans; +""" + SQL(query) + .on(keywordParams: _*) + .as(this.getTaskClusterSummaryParser().*) + .filter(_ != None) + .asInstanceOf[List[TaskClusterSummary]] + } + } + + /** + * Queries task markers with bounding box filtering + * + * @param statuses List of task status filters + * @param global Whether to include global challenges + * @param boundingBox Search parameters including bounding box + * @return List of task markers within the bounding box + */ + def queryTaskMarkersWithBoundingBox( + statuses: List[Int], + global: Boolean, + boundingBox: SearchLocation, + keywords: Option[String] = None, + difficulty: Option[Int] = None + ): List[TaskMarker] = { + this.withMRTransaction { implicit c => + val keywordList = parseKeywords(keywords) + val keywordParams = keywordNamedParameters(keywordList) + + var query = + """ + SELECT DISTINCT tasks.id, ST_AsGeoJSON(tasks.location) AS location, tasks.status, tasks.priority, + tasks.bundle_id, l.user_id as locked_by + FROM tasks + INNER JOIN challenges c ON c.id = tasks.parent_id + INNER JOIN projects p ON p.id = c.parent_id + LEFT JOIN locked l ON l.item_id = tasks.id AND l.item_type = 2 + """ + + // Add joins for keywords filtering if keywords are provided + if (keywordList.nonEmpty) { + query += " INNER JOIN tags_on_challenges toc ON c.id = toc.challenge_id" + query += " INNER JOIN tags t ON toc.tag_id = t.id" + } + + query += """ + WHERE c.deleted = false + AND c.enabled = true + AND c.is_archived = false + AND p.deleted = false + AND p.enabled = true + AND tasks.location IS NOT NULL + """ + + if (!global) { + query += " AND c.is_global = false" + } + + if (statuses.nonEmpty) { + query += s" AND tasks.status IN (${statuses.mkString(",")})" + } + + // Filter by keywords if provided (bound as parameters, not interpolated) + if (keywordList.nonEmpty) { + query += " " + keywordInClause("t.name", keywordList) + } + + // Filter by difficulty if provided + difficulty.foreach { diff => + query += s" AND c.difficulty = $diff" + } + + var left = boundingBox.left + var bottom = boundingBox.bottom + var right = boundingBox.right + var top = boundingBox.top + query += s" AND ST_Intersects(tasks.location, ST_MakeEnvelope($left, $bottom, $right, $top, 4326))" + + SQL(query) + .on(keywordParams: _*) + .as( + (int("id") ~ str("location") ~ int("status") ~ int("priority") ~ get[Option[Long]]( + "bundle_id" + ) ~ get[Option[Long]]("locked_by")).map { + case id ~ location ~ status ~ priority ~ bundleId ~ lockedBy => + val locationJSON = Json.parse(location) + val coordinates = (locationJSON \ "coordinates").as[List[Double]] + TaskMarker( + id, + TaskMarkerLocation(coordinates(1), coordinates.head), + status, + priority, + bundleId, + lockedBy + ) + }.* + ) + } + } + + /** + * Queries task markers with bounding box filtering and overlap detection. + * Uses PostGIS ST_ClusterDBSCAN to detect tasks at the same location. + * + * @param statuses List of task status filters + * @param global Whether to include global challenges + * @param boundingBox Search parameters including bounding box + * @param keywords Optional comma-separated list of keywords to filter by + * @param difficulty Optional difficulty level to filter by + * @return Tuple of (single task markers, overlapping task markers) + */ + def queryTaskMarkersWithOverlaps( + statuses: List[Int], + global: Boolean, + boundingBox: SearchLocation, + keywords: Option[String] = None, + difficulty: Option[Int] = None + ): (List[TaskMarker], List[OverlappingTaskMarker]) = { + this.withMRTransaction { implicit c => + val left = boundingBox.left + val bottom = boundingBox.bottom + val right = boundingBox.right + val top = boundingBox.top + val statusList = statuses.mkString(",") + + val keywordList = parseKeywords(keywords) + val keywordParams = keywordNamedParameters(keywordList) + + // Build joins for keywords filtering if keywords are provided + val keywordJoins = if (keywordList.nonEmpty) { + " INNER JOIN tags_on_challenges toc ON c.id = toc.challenge_id" + + " INNER JOIN tags tags_table ON toc.tag_id = tags_table.id" + } else "" + + val keywordFilter = keywordInClause("tags_table.name", keywordList) + + val difficultyFilter = difficulty.map(d => s"AND c.difficulty = $d").getOrElse("") + + val globalFilter = if (!global) "AND c.is_global = false" else "" + + val statusFilter = if (statuses.nonEmpty) s"AND tasks.status IN ($statusList)" else "" + + // Use PostGIS ST_ClusterDBSCAN for efficient overlap detection + // eps = 0.000001 degrees (~0.1 meters), minpoints = 1 to include all points + val query = s""" + SELECT + tasks.id, + ST_Y(tasks.location) as lat, + ST_X(tasks.location) as lng, + tasks.status, + tasks.priority, + tasks.bundle_id, + l.user_id as locked_by, + ST_ClusterDBSCAN(tasks.location, eps := 0.000001, minpoints := 1) OVER () as cluster_id + FROM tasks + INNER JOIN challenges c ON c.id = tasks.parent_id + INNER JOIN projects p ON p.id = c.parent_id + LEFT JOIN locked l ON l.item_id = tasks.id AND l.item_type = 2 + $keywordJoins + WHERE c.deleted = false + AND c.enabled = true + AND c.is_archived = false + AND p.deleted = false + AND p.enabled = true + AND tasks.location IS NOT NULL + AND ST_Intersects(tasks.location, ST_MakeEnvelope($left, $bottom, $right, $top, 4326)) + $globalFilter + $statusFilter + $keywordFilter + $difficultyFilter + """ + + val allTasks = SQL(query) + .on(keywordParams: _*) + .as( + (get[Long]("id") ~ get[Double]("lat") ~ get[Double]("lng") ~ get[Int]("status") ~ get[ + Int + ]( + "priority" + ) ~ get[Option[Long]]("bundle_id") ~ get[Option[Long]]("locked_by") ~ get[Int]( + "cluster_id" + )).map { + case taskId ~ lat ~ lng ~ status ~ priority ~ bundleId ~ lockedBy ~ clusterId => + ( + taskId, + TaskMarkerLocation(lat, lng), + status, + priority, + bundleId, + lockedBy, + clusterId + ) + }.* + ) + + // Group by cluster_id + val clusters = allTasks.groupBy(_._7) + + val singleMarkers = scala.collection.mutable.ListBuffer[TaskMarker]() + val overlappingGroups = scala.collection.mutable.ListBuffer[OverlappingTaskMarker]() + + clusters.values.foreach { clusterTasks => + if (clusterTasks.length == 1) { + val (taskId, location, status, priority, bundleId, lockedBy, _) = clusterTasks.head + singleMarkers += TaskMarker(taskId, location, status, priority, bundleId, lockedBy) + } else { + val location = clusterTasks.head._2 + val tasksInCluster = clusterTasks.map { + case (tId, tLoc, tStatus, tPriority, tBundleId, tLockedBy, _) => + TaskMarker(tId, tLoc, tStatus, tPriority, tBundleId, tLockedBy) + }.toList + overlappingGroups += OverlappingTaskMarker(location, tasksInCluster) + } + } + + (singleMarkers.toList, overlappingGroups.toList) + } + } + + /** + * Counts task markers in the given bounding box + * + * @param statuses List of task status filters + * @param global Whether to include global challenges + * @param boundingBox Search parameters including bounding box + * @return Count of task markers + */ + def queryCountTaskMarkers( + statuses: List[Int], + global: Boolean, + boundingBox: SearchLocation, + keywords: Option[String] = None, + difficulty: Option[Int] = None + ): Int = { + this.withMRTransaction { implicit c => + val keywordList = parseKeywords(keywords) + val keywordParams = keywordNamedParameters(keywordList) + + var query = + """ + SELECT COUNT(DISTINCT tasks.id) as count + FROM tasks + INNER JOIN challenges c ON c.id = tasks.parent_id + INNER JOIN projects p ON p.id = c.parent_id + """ + + // Add joins for keywords filtering if keywords are provided + if (keywordList.nonEmpty) { + query += " INNER JOIN tags_on_challenges toc ON c.id = toc.challenge_id" + query += " INNER JOIN tags t ON toc.tag_id = t.id" + } + + query += """ + WHERE c.deleted = false + AND c.enabled = true + AND c.is_archived = false + AND p.deleted = false + AND p.enabled = true + AND tasks.location IS NOT NULL + """ + + if (!global) { + query += " AND c.is_global = false" + } + + if (statuses.nonEmpty) { + query += s" AND tasks.status IN (${statuses.mkString(",")})" + } + + // Filter by keywords if provided (bound as parameters, not interpolated) + if (keywordList.nonEmpty) { + query += " " + keywordInClause("t.name", keywordList) + } + + // Filter by difficulty if provided + difficulty.foreach { diff => + query += s" AND c.difficulty = $diff" + } + + var left = boundingBox.left + var bottom = boundingBox.bottom + var right = boundingBox.right + var top = boundingBox.top + query += s" AND ST_Intersects(tasks.location, ST_MakeEnvelope($left, $bottom, $right, $top, 4326))" + + SQL(query).on(keywordParams: _*).as(int("count").single) + } + } + + /** Split a comma-separated keyword string into normalized, non-empty terms. */ + private def parseKeywords(keywords: Option[String]): List[String] = + keywords + .map(_.split(",").map(_.trim.toLowerCase).filter(_.nonEmpty).toList) + .getOrElse(Nil) + + /** Bound parameters (kw0, kw1, ...) for a parsed keyword list. */ + private def keywordNamedParameters(keywordList: List[String]): Seq[NamedParameter] = + keywordList.zipWithIndex.map { case (kw, i) => NamedParameter(s"kw$i", kw) } + + /** + * `AND LOWER() IN ({kw0}, {kw1}, ...)` clause for a parsed keyword + * list, or "" when empty. Values are bound (see keywordNamedParameters), not + * interpolated, so the keyword string cannot be a SQL-injection vector. + */ + private def keywordInClause(column: String, keywordList: List[String]): String = + if (keywordList.isEmpty) "" + else + s"AND LOWER($column) IN (" + keywordList.indices.map(i => s"{kw$i}").mkString(", ") + ")" } diff --git a/app/org/maproulette/framework/repository/TaskRepository.scala b/app/org/maproulette/framework/repository/TaskRepository.scala index ce4d194dd..6f9896a8d 100644 --- a/app/org/maproulette/framework/repository/TaskRepository.scala +++ b/app/org/maproulette/framework/repository/TaskRepository.scala @@ -5,7 +5,7 @@ package org.maproulette.framework.repository -import anorm.SqlParser.{get, scalar, str} +import anorm.SqlParser.scalar import anorm.ToParameterValue import anorm._, postgresql._ import javax.inject.{Inject, Singleton} @@ -43,37 +43,11 @@ class TaskRepository @Inject() (override val db: Database, config: Config) "WHERE tasks.id = {id}" SQL(query) .on(Symbol("id") -> id) - .as(this.getTaskParser(this.updateAndRetrieve).singleOpt) + .as(this.getTaskParser().singleOpt) } }(id) } - /** - * Allows us to lazy update the geojson data - * - * @param taskId The identifier of the task - */ - def updateAndRetrieve( - taskId: Long, - geojson: Option[String], - location: Option[String], - cooperativeWork: Option[String] - ): (String, Option[String], Option[String]) = { - geojson match { - case Some(g) => (g, location, cooperativeWork) - case None => - this.withMRTransaction { implicit c => - SQL("SELECT * FROM update_geometry({id})") - .on(Symbol("id") -> taskId) - .as((str("geo") ~ get[Option[String]]("loc") ~ get[Option[String]]("fix_geo")).*) - .headOption match { - case Some(values) => (values._1._1, values._1._2, values._2) - case None => throw new Exception("Failed to retrieve task data") - } - } - } - } - /** * Updates the completionResponses on a Task * @@ -119,6 +93,77 @@ class TaskRepository @Inject() (override val db: Database, config: Config) } } + /** + * Increment the skip_count on a task. Does NOT change the task's status + * or touch the lock record — callers should release the lock separately. + * + * @param taskId The id of the task being skipped + * @return The number of rows updated (0 or 1) + */ + def incrementSkipCount(taskId: Long): Int = { + this.withMRConnection { implicit c => + SQL("UPDATE tasks SET skip_count = skip_count + 1 WHERE id = {id}") + .on(Symbol("id") -> taskId) + .executeUpdate() + } + } + + /** + * Delete tasks by id list. Cascade deletes via FK constraints handle + * task_review, task tags, comments, etc. + * + * @param taskIds The task ids to delete + * @return The number of rows deleted + */ + def bulkDeleteTasks(taskIds: List[Long]): Int = { + if (taskIds.isEmpty) return 0 + this.withMRTransaction { implicit c => + SQL("DELETE FROM task_review WHERE task_id IN ({ids})") + .on(Symbol("ids") -> taskIds) + .executeUpdate() + SQL("DELETE FROM tasks WHERE id IN ({ids})") + .on(Symbol("ids") -> taskIds) + .executeUpdate() + } + } + + /** + * Archive / unarchive tasks in bulk. + * + * @param taskIds The task ids to update + * @param archived Target archived flag + * @return Number of rows updated + */ + def bulkArchiveTasks(taskIds: List[Long], archived: Boolean): Int = { + if (taskIds.isEmpty) return 0 + this.withMRConnection { implicit c => + SQL("UPDATE tasks SET archived = {archived} WHERE id IN ({ids})") + .on(Symbol("archived") -> archived, Symbol("ids") -> taskIds) + .executeUpdate() + } + } + + /** + * Reassign the review of a batch of tasks to another user. Only tasks + * whose reviews are currently "needed" or "requested" (review_status in + * {0, 3}) are reassigned; other reviews are left alone. + * + * @param taskIds Task ids to reassign + * @param userId Target reviewer user id + * @return Number of rows updated + */ + def bulkReassignReviewer(taskIds: List[Long], userId: Long): Int = { + if (taskIds.isEmpty) return 0 + this.withMRConnection { implicit c => + SQL( + """UPDATE task_review + SET reviewed_by = {userId}, review_claimed_by = {userId}, review_claimed_at = NOW() + WHERE task_id IN ({ids}) AND review_status IN (0, 3)""" + ).on(Symbol("userId") -> userId, Symbol("ids") -> taskIds) + .executeUpdate() + } + } + /** * Retrieve a task attachment identified by attachmentId * diff --git a/app/org/maproulette/framework/repository/TaskReviewRepository.scala b/app/org/maproulette/framework/repository/TaskReviewRepository.scala index 1f29267c0..f1d24426f 100644 --- a/app/org/maproulette/framework/repository/TaskReviewRepository.scala +++ b/app/org/maproulette/framework/repository/TaskReviewRepository.scala @@ -35,8 +35,8 @@ class TaskReviewRepository @Inject() ( implicit val baseTable: String = TaskReview.TABLE protected val logger = LoggerFactory.getLogger(this.getClass) - val parser = this.getTaskParser(this.taskRepository.updateAndRetrieve) - val reviewParser = this.getTaskWithReviewParser(this.taskRepository.updateAndRetrieve) + val parser = this.getTaskParser() + val reviewParser = this.getTaskWithReviewParser() /** * Gets a Task object with review data diff --git a/app/org/maproulette/framework/repository/TileAggregateRepository.scala b/app/org/maproulette/framework/repository/TileAggregateRepository.scala new file mode 100644 index 000000000..589fc4117 --- /dev/null +++ b/app/org/maproulette/framework/repository/TileAggregateRepository.scala @@ -0,0 +1,384 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + +package org.maproulette.framework.repository + +import java.sql.Connection + +import anorm._ +import anorm.SqlParser.get +import javax.inject.{Inject, Singleton} +import play.api.db.Database + +/** + * Repository backing the pre-computed tile cells and on-demand MVT generation. + * + * Tile building standard: + * - Zoom 0..11: pre-computed grid cells in `tile_cells`. A cell at display + * zoom z is a slippy tile at zoom z + CELL_BITS; clustering is just grid + * binning, so it is exact, deterministic and identical for filtered and + * unfiltered requests. + * - Zoom 12: served live from `tasks` as individual / overlap-deduped + * markers (one feature per distinct ground location). The frontend + * overzooms this for z = 13+. + * + * All four code paths (this repository's live queries, plus `rebuild_leaf_cell` + * and `rebuild_all_tile_cells` in evolution 107) share one eligibility filter: + * a task is available work when it has a valid location, `status IN (0,3,6)`, + * is not archived, and its challenge/project are enabled and not deleted or + * archived. `enabled` is MapRoulette's "discoverable" flag, so requiring it on + * both challenge and project keeps hidden work off the explore map. Keep all + * four paths in sync. + */ +@Singleton +class TileAggregateRepository @Inject() (override val db: Database) extends RepositoryMixin { + implicit val baseTable: String = "tile_cells" + + // Web Mercator world extent in meters (half of total extent). + private val WEB_MERCATOR_EXTENT = 20037508.342789244 + + // A cell at display zoom z is a slippy tile at zoom z + CELL_BITS, so each + // display tile is a 2^CELL_BITS square of cells. Must match evolution 107. + private val CELL_BITS = 4 + + /** Highest display zoom served as pre-computed grid cells. */ + val MAX_CELL_ZOOM = 11 + + /** Display zoom served live as individual task markers. */ + val TASK_ZOOM = 12 + + // --------------------------------------------------------------------------- + // MVT generation + // --------------------------------------------------------------------------- + + /** + * MVT for display zoom 0..11 with no keyword/location filter. Served from the + * pre-computed `tile_cells` table; difficulty/global are applied by summing + * `counts_by_filter` buckets. + */ + def getMvtCellsPrecomputed( + z: Int, + x: Int, + y: Int, + difficulty: Option[Int], + global: Boolean + )(implicit c: Option[Connection] = None): Array[Byte] = { + this.withMRConnection { implicit c => + val (xMin, yMin, xMax, yMax) = tileBounds3857(z, x, y) + val (cxMin, cyMin, cxMax, cyMax) = cellRange(x, y) + + // counts_by_filter has a fixed, code-controlled key set, so composing the + // keys into a SQL expression is safe (no user input is interpolated). + val countExpr = buildFilterCountKeys(difficulty, global) + .map(k => s"COALESCE((tc.counts_by_filter->>'$k')::int, 0)") + .mkString(" + ") + + SQL""" + SELECT COALESCE(ST_AsMVT(tile, 'default', 4096, 'geom'), ''::bytea) AS mvt + FROM ( + SELECT + ST_AsMVTGeom( + ST_Transform( + ST_SetSRID(ST_MakePoint(tc.sum_lng / tc.task_count, tc.sum_lat / tc.task_count), 4326), + 3857), + ST_MakeEnvelope($xMin, $yMin, $xMax, $yMax, 3857), + 4096, 64, true + ) AS geom, + 2 AS group_type, + (#$countExpr) AS task_count + FROM tile_cells tc + WHERE tc.z = $z + AND tc.cx BETWEEN $cxMin AND $cxMax + AND tc.cy BETWEEN $cyMin AND $cyMax + AND tc.task_count > 0 + AND (#$countExpr) > 0 + ) AS tile + """.as(get[Array[Byte]]("mvt").single) + } + } + + /** + * MVT for display zoom 0..11 with keyword filters. Tasks are grid-binned on + * the fly using the *same* cell grid as the pre-computed path, so a filtered + * map clusters identically to an unfiltered one. + */ + def getMvtCellsLive( + z: Int, + x: Int, + y: Int, + difficulty: Option[Int], + global: Boolean, + keywords: Option[String] + )(implicit c: Option[Connection] = None): Array[Byte] = { + this.withMRConnection { implicit c => + val (xMin, yMin, xMax, yMax) = tileBounds3857(z, x, y) + val cellZoom = z + CELL_BITS + val filter = liveFilter(difficulty, global, keywords) + + val query = s""" + WITH binned AS ( + SELECT + lng_to_tile_x(ST_X(t.location), $cellZoom) AS cx, + lat_to_tile_y(ST_Y(t.location), $cellZoom) AS cy, + COUNT(*)::int AS task_count, + SUM(ST_Y(t.location)) AS sum_lat, + SUM(ST_X(t.location)) AS sum_lng + FROM tasks t + INNER JOIN challenges c ON c.id = t.parent_id + INNER JOIN projects p ON p.id = c.parent_id + ${filter.joins} + WHERE t.location && ST_Transform( + ST_MakeEnvelope({xMin}, {yMin}, {xMax}, {yMax}, 3857), 4326) + AND NOT ST_IsEmpty(t.location) + ${filter.where} + GROUP BY 1, 2 + ) + SELECT COALESCE(ST_AsMVT(tile, 'default', 4096, 'geom'), ''::bytea) AS mvt + FROM ( + SELECT + ST_AsMVTGeom( + ST_Transform( + ST_SetSRID(ST_MakePoint(sum_lng / task_count, sum_lat / task_count), 4326), + 3857), + ST_MakeEnvelope({xMin}, {yMin}, {xMax}, {yMax}, 3857), + 4096, 64, true + ) AS geom, + 2 AS group_type, + task_count + FROM binned + ) AS tile + """ + + val params = boundsParams(xMin, yMin, xMax, yMax) ++ filter.params + SQL(query).on(params: _*).as(get[Array[Byte]]("mvt").single) + } + } + + /** + * MVT for display zoom 12, served live from `tasks`. Emits one feature per + * distinct ground location: `group_type=0` for a lone task (with id/status/ + * priority), `group_type=1` for an overlap stack (with `task_ids_str`). + * Used for every z=12 request, filtered or not. + */ + def getMvtTasksLive( + z: Int, + x: Int, + y: Int, + difficulty: Option[Int], + global: Boolean, + keywords: Option[String] + )(implicit c: Option[Connection] = None): Array[Byte] = { + this.withMRConnection { implicit c => + val (xMin, yMin, xMax, yMax) = tileBounds3857(z, x, y) + val filter = liveFilter(difficulty, global, keywords) + + val query = s""" + WITH eligible AS ( + SELECT t.id, t.status, t.priority, t.parent_id AS challenge_id, t.location + FROM tasks t + INNER JOIN challenges c ON c.id = t.parent_id + INNER JOIN projects p ON p.id = c.parent_id + ${filter.joins} + WHERE t.location && ST_Transform( + ST_MakeEnvelope({xMin}, {yMin}, {xMax}, {yMax}, 3857), 4326) + AND NOT ST_IsEmpty(t.location) + ${filter.where} + ), + grouped AS ( + SELECT + ST_SnapToGrid(location, 0.0000001) AS snap, + COUNT(*)::int AS task_count, + (ARRAY_AGG(id ORDER BY id))[1] AS single_id, + (ARRAY_AGG(status ORDER BY id))[1] AS single_status, + (ARRAY_AGG(priority ORDER BY id))[1] AS single_priority, + (ARRAY_AGG(challenge_id ORDER BY id))[1] AS single_challenge_id, + array_to_string(ARRAY_AGG(id ORDER BY id), ',') AS task_ids_str, + ST_Centroid(ST_Collect(location)) AS centroid + FROM eligible + GROUP BY 1 + ) + SELECT COALESCE(ST_AsMVT(tile, 'default', 4096, 'geom'), ''::bytea) AS mvt + FROM ( + SELECT + ST_AsMVTGeom( + ST_Transform(centroid, 3857), + ST_MakeEnvelope({xMin}, {yMin}, {xMax}, {yMax}, 3857), + 4096, 64, true + ) AS geom, + CASE WHEN task_count = 1 THEN 0 ELSE 1 END AS group_type, + task_count, + CASE WHEN task_count = 1 THEN single_id ELSE NULL END AS id, + CASE WHEN task_count = 1 THEN single_status ELSE NULL END AS status, + CASE WHEN task_count = 1 THEN single_priority ELSE NULL END AS priority, + CASE WHEN task_count = 1 THEN single_challenge_id ELSE NULL END AS challenge_id, + CASE WHEN task_count > 1 THEN task_ids_str ELSE NULL END AS task_ids_str + FROM grouped + ) AS tile + """ + + val params = boundsParams(xMin, yMin, xMax, yMax) ++ filter.params + SQL(query).on(params: _*).as(get[Array[Byte]]("mvt").single) + } + } + + // --------------------------------------------------------------------------- + // Dirty-cell queue + // --------------------------------------------------------------------------- + + /** + * Drain the dirty-cell queue: recompute up to `limit` leaf cells from the + * base tables and roll the changes up to z=0. `newestFirst` drains the most + * recently marked cells first (used by the synchronous post-commit drain). + * Returns the number of leaf cells processed. + */ + def rebuildDirtyCells( + limit: Int = 512, + newestFirst: Boolean = false + )(implicit c: Option[Connection] = None): Int = { + this.withMRTransaction { implicit c => + SQL"SELECT rebuild_dirty_cells($limit, $newestFirst) AS n" + .as(SqlParser.int("n").single) + } + } + + /** Full rebuild of the whole pyramid. Returns the number of cells created. */ + def rebuildAll()(implicit c: Option[Connection] = None): Int = { + this.withMRTransaction { implicit c => + SQL"SELECT rebuild_all_tile_cells() AS n".as(SqlParser.int("n").single) + } + } + + /** Total number of pre-computed grid cells across all zoom levels. */ + def getCellCount()(implicit c: Option[Connection] = None): Int = { + this.withMRConnection { implicit c => + SQL"SELECT COUNT(*)::int AS count FROM tile_cells" + .as(SqlParser.int("count").single) + } + } + + /** Number of leaf cells currently waiting for a recompute. */ + def getDirtyCellCount()(implicit c: Option[Connection] = None): Int = { + this.withMRConnection { implicit c => + SQL"SELECT COUNT(*)::int AS count FROM tile_dirty_cells" + .as(SqlParser.int("count").single) + } + } + + /** + * Age in seconds of the oldest entry in the dirty-cell queue, or 0 when the + * queue is empty. A climbing value means the drain is falling behind. + */ + def getDirtyQueueLagSeconds()(implicit c: Option[Connection] = None): Int = { + this.withMRConnection { implicit c => + SQL"""SELECT COALESCE( + EXTRACT(EPOCH FROM (NOW() - MIN(marked_at))), 0)::int AS lag + FROM tile_dirty_cells""" + .as(SqlParser.int("lag").single) + } + } + + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + /** Shared FROM-join / WHERE fragment + bound parameters for the live paths. */ + private case class LiveFilter(joins: String, where: String, params: Seq[NamedParameter]) + + /** + * Build the eligibility + difficulty/global/keyword filter shared by the live + * MVT queries. All user-provided values are bound parameters; only + * code-controlled identifiers are interpolated. + */ + private def liveFilter( + difficulty: Option[Int], + global: Boolean, + keywords: Option[String] + ): LiveFilter = { + val keywordList = keywords + .map(_.split(",").map(_.trim.toLowerCase).filter(_.nonEmpty).toList) + .getOrElse(Nil) + val hasKeywords = keywordList.nonEmpty + + val joins = + if (hasKeywords) + "INNER JOIN tags_on_challenges toc ON c.id = toc.challenge_id " + + "INNER JOIN tags tg ON toc.tag_id = tg.id" + else "" + + val keywordParamNames = keywordList.indices.map(i => s"kw$i").toList + val keywordClause = + if (hasKeywords) + "AND LOWER(tg.name) IN (" + keywordParamNames.map(n => s"{$n}").mkString(", ") + ")" + else "" + val difficultyClause = if (difficulty.isDefined) "AND c.difficulty = {difficulty}" else "" + val globalClause = if (!global) "AND c.is_global = false" else "" + + val where = + s"""AND t.status IN (0, 3, 6) + AND t.archived = false + AND c.deleted = false AND c.enabled = true AND c.is_archived = false + AND p.deleted = false AND p.enabled = true + $globalClause + $difficultyClause + $keywordClause""" + + val params = scala.collection.mutable.ListBuffer[NamedParameter]() + keywordParamNames.zip(keywordList).foreach { + case (name, value) => params += NamedParameter(name, value) + } + if (difficulty.isDefined) params += NamedParameter("difficulty", difficulty.get) + + LiveFilter(joins, where, params.toSeq) + } + + private def boundsParams( + xMin: Double, + yMin: Double, + xMax: Double, + yMax: Double + ): Seq[NamedParameter] = + Seq( + NamedParameter("xMin", xMin), + NamedParameter("yMin", yMin), + NamedParameter("xMax", xMax), + NamedParameter("yMax", yMax) + ) + + /** + * Build the list of `counts_by_filter` JSON keys to sum for the given + * filters. Returns a hardcoded key set — safe to interpolate into SQL. + */ + private def buildFilterCountKeys(difficulty: Option[Int], global: Boolean): List[String] = { + val difficulties = difficulty match { + case Some(d) if d >= 1 && d <= 3 => List(s"d$d") + case _ => List("d1", "d2", "d3", "d0") + } + val globals = if (global) List("gf", "gt") else List("gf") + for { + d <- difficulties + g <- globals + } yield s"${d}_${g}" + } + + /** Inclusive cell-coordinate range covered by display tile (x, y). */ + private def cellRange(x: Int, y: Int): (Int, Int, Int, Int) = { + val span = 1 << CELL_BITS + (x << CELL_BITS, y << CELL_BITS, (x << CELL_BITS) + span - 1, (y << CELL_BITS) + span - 1) + } + + /** + * Tile bounds in Web Mercator (SRID 3857) for standard z/x/y. + * Returns (xMin, yMin, xMax, yMax) in meters. + */ + private def tileBounds3857(z: Int, x: Int, y: Int): (Double, Double, Double, Double) = { + val worldSize = WEB_MERCATOR_EXTENT * 2 + val tileSize = worldSize / (1L << z) + val xMin = -WEB_MERCATOR_EXTENT + x * tileSize + val xMax = -WEB_MERCATOR_EXTENT + (x + 1) * tileSize + val yMax = WEB_MERCATOR_EXTENT - y * tileSize + val yMin = WEB_MERCATOR_EXTENT - (y + 1) * tileSize + (xMin, yMin, xMax, yMax) + } +} diff --git a/app/org/maproulette/framework/repository/UserRepository.scala b/app/org/maproulette/framework/repository/UserRepository.scala index 29ea83507..4984c42aa 100644 --- a/app/org/maproulette/framework/repository/UserRepository.scala +++ b/app/org/maproulette/framework/repository/UserRepository.scala @@ -17,7 +17,7 @@ import org.maproulette.framework.psql.{Query, SQLUtils} import org.maproulette.framework.service.{GrantService, ServiceManager} import org.maproulette.models.dal.ChallengeDAL import play.api.db.Database -import play.api.libs.json.{JsResultException, Json} +import play.api.libs.json.{JsObject, JsResultException, Json} import java.sql.Connection import javax.inject.{Inject, Singleton} @@ -134,7 +134,7 @@ class UserRepository @Inject() ( Symbol("isReviewer") -> user.settings.isReviewer, Symbol("theme") -> user.settings.theme, Symbol("allowFollowing") -> user.settings.allowFollowing, - Symbol("properties") -> user.properties, + Symbol("properties") -> user.properties.map(Json.stringify), Symbol("seeTagFixSuggestions") -> user.settings.seeTagFixSuggestions, Symbol("disableTaskConfirm") -> user.settings.disableTaskConfirm, Symbol("showPriorityMarkerColors") -> user.settings.showPriorityMarkerColors @@ -519,13 +519,14 @@ object UserRepository { get[Option[Long]]("users.followers_group") ~ get[Option[Boolean]]("users.see_tag_fix_suggestions") ~ get[Option[Boolean]]("users.disable_task_confirm") ~ - get[Option[Boolean]]("users.show_priority_marker_colors") map { + get[Option[Boolean]]("users.show_priority_marker_colors") ~ + get[Option[String]]("users.plugins").? map { case id ~ osmId ~ created ~ modified ~ osmCreated ~ displayName ~ description ~ avatarURL ~ homeLocation ~ apiKey ~ oauthToken ~ defaultEditor ~ defaultBasemap ~ defaultBasemapId ~ customBasemapList ~ email ~ emailOptIn ~ leaderboardOptOut ~ needsReview ~ isReviewer ~ locale ~ theme ~ properties ~ score ~ achievements ~ allowFollowing ~ followingGroupId ~ followersGroupId ~ - seeTagFixSuggestions ~ disableTaskConfirm ~ showPriorityMarkerColors => + seeTagFixSuggestions ~ disableTaskConfirm ~ showPriorityMarkerColors ~ plugins => val locationWKT = homeLocation match { case Some(wkt) => new WKTReader().read(wkt).asInstanceOf[Point] case None => new GeometryFactory().createPoint(new Coordinate(0, 0)) @@ -573,9 +574,10 @@ object UserRepository { customBasemaps, seeTagFixSuggestions, disableTaskConfirm, - showPriorityMarkerColors + showPriorityMarkerColors, + plugins.flatten ), - properties, + properties.map(Json.parse(_).as[JsObject]), score, followingGroupId, followersGroupId, diff --git a/app/org/maproulette/framework/repository/UserSavedObjectsRepository.scala b/app/org/maproulette/framework/repository/UserSavedObjectsRepository.scala index 3132baf1a..c07c4db03 100644 --- a/app/org/maproulette/framework/repository/UserSavedObjectsRepository.scala +++ b/app/org/maproulette/framework/repository/UserSavedObjectsRepository.scala @@ -227,4 +227,99 @@ class UserSavedObjectsRepository @Inject() ( .execute() } } + + /** + * Checks if a challenge is saved (favorited) by a user + * + * @param userId The id of the user + * @param challengeId The id of the challenge + * @param c The existing connection if any + * @return true if the challenge is saved by the user + */ + def isChallengeSaved(userId: Long, challengeId: Long)( + implicit c: Option[Connection] = None + ): Boolean = { + this.withMRTransaction { implicit c => + SQL( + s"""SELECT COUNT(*) FROM saved_challenges + |WHERE user_id = {uid} AND challenge_id = {cid}""".stripMargin + ).on(Symbol("uid") -> userId, Symbol("cid") -> challengeId) + .as(scalar[Long].single) > 0 + } + } + + /** + * Likes a challenge for the user + * + * @param userId The id of the user + * @param challengeId the id of the challenge + * @param c The existing connection if any + */ + def likeChallenge(userId: Long, challengeId: Long)( + implicit c: Option[Connection] = None + ): Unit = { + this.withMRTransaction { implicit c => + SQL( + s"""INSERT INTO challenge_likes (user_id, challenge_id) + | VALUES ({uid}, {cid}) ON + | CONFLICT (user_id, challenge_id) DO NOTHING""".stripMargin + ).on(Symbol("uid") -> userId, Symbol("cid") -> challengeId).executeInsert() + } + } + + /** + * Unlikes a challenge from the users profile + * + * @param userId The id of the user that has previously liked the challenge + * @param challengeId The id of the challenge to remove the like from + * @param c The existing connection if any + */ + def unlikeChallenge(userId: Long, challengeId: Long)( + implicit c: Option[Connection] = None + ): Unit = { + this.withMRTransaction { implicit c => + SQL( + s"""DELETE FROM challenge_likes WHERE user_id = {uid} AND challenge_id = {cid}""" + ).on(Symbol("uid") -> userId, Symbol("cid") -> challengeId).execute() + } + } + + /** + * Checks if a challenge is liked by a user + * + * @param userId The id of the user + * @param challengeId The id of the challenge + * @param c The existing connection if any + * @return true if the challenge is liked by the user + */ + def isChallengeLiked(userId: Long, challengeId: Long)( + implicit c: Option[Connection] = None + ): Boolean = { + this.withMRTransaction { implicit c => + SQL( + s"""SELECT COUNT(*) FROM challenge_likes + |WHERE user_id = {uid} AND challenge_id = {cid}""".stripMargin + ).on(Symbol("uid") -> userId, Symbol("cid") -> challengeId) + .as(scalar[Long].single) > 0 + } + } + + /** + * Gets the total like count for a challenge + * + * @param challengeId The id of the challenge + * @param c The existing connection if any + * @return The total number of likes for the challenge + */ + def getChallengeLikeCount(challengeId: Long)( + implicit c: Option[Connection] = None + ): Long = { + this.withMRTransaction { implicit c => + SQL( + s"""SELECT COUNT(*) FROM challenge_likes + |WHERE challenge_id = {cid}""".stripMargin + ).on(Symbol("cid") -> challengeId) + .as(scalar[Long].single) + } + } } diff --git a/app/org/maproulette/framework/service/CommentService.scala b/app/org/maproulette/framework/service/CommentService.scala index b71a1d17e..c01603f3a 100644 --- a/app/org/maproulette/framework/service/CommentService.scala +++ b/app/org/maproulette/framework/service/CommentService.scala @@ -246,6 +246,50 @@ class CommentService @Inject() ( challengeComments } + /** + * Searches task comments by a search term, scoped to comments the requesting + * user authored or comments on challenges the user owns. + * + * @param searchTerm The term to search within the comments + * @param user The authenticated user making the request + * @param limit The maximum number of comments to return + * @param page The page number for pagination + * @return a list of matching comments + */ + def searchComments( + searchTerm: String, + user: User, + limit: Int = 25, + page: Int = 0 + ): List[Comment] = { + this.repository.searchComments(searchTerm, user.id, user.osmProfile.id, limit, page) + } + + /** + * Searches challenge comments by a search term, scoped to comments the + * requesting user authored or comments on challenges the user owns. + * + * @param searchTerm The term to search within the comments + * @param user The authenticated user making the request + * @param limit The maximum number of comments to return + * @param page The page number for pagination + * @return a list of matching challenge comments + */ + def searchChallengeComments( + searchTerm: String, + user: User, + limit: Int = 25, + page: Int = 0 + ): List[ChallengeComment] = { + this.challengeCommentRepository.searchComments( + searchTerm, + user.id, + user.osmProfile.id, + limit, + page + ) + } + /** * Retrieves the comments based on the input criteria * diff --git a/app/org/maproulette/framework/service/ProjectService.scala b/app/org/maproulette/framework/service/ProjectService.scala index 7d8a5b1ee..5b5b28a23 100644 --- a/app/org/maproulette/framework/service/ProjectService.scala +++ b/app/org/maproulette/framework/service/ProjectService.scala @@ -14,6 +14,7 @@ import org.maproulette.framework.model._ import org.maproulette.framework.psql._ import org.maproulette.framework.psql.filter._ import org.maproulette.framework.repository.{ChallengeRepository, ProjectRepository} +import anorm._ import org.maproulette.permissions.Permission import org.maproulette.session.SearchParameters import org.slf4j.LoggerFactory @@ -238,6 +239,69 @@ class ProjectService @Inject() ( this.query(query) } + def search( + search: String, + limit: Int = 25, + onlyEnabled: Boolean = false + ): List[Project] = { + val isNumeric = search.matches("^\\d+$") + val searchLong = if (isNumeric) Some(search.toLong) else None + val searchPattern = if (search.nonEmpty) s"%$search%" else "%" + val enabledClause = if (onlyEnabled) "enabled = true AND " else "" + + repository.withMRConnection { implicit c => + val parser = ProjectRepository.parser(id => + this.serviceManager.grant.retrieveGrantsOn(GrantTarget.project(id), User.superUser) + ) + + if (isNumeric && searchLong.isDefined) { + SQL( + s"SELECT * FROM projects WHERE id = {id}${if (onlyEnabled) " AND enabled = true" else ""}" + ).on("id" -> searchLong.get) + .as(parser.*) + } else if (search.nonEmpty) { + SQL(s"""SELECT * FROM projects + WHERE ${enabledClause}( + LOWER(name) LIKE LOWER({search}) + OR LOWER(display_name) LIKE LOWER({search}) + OR (name <> '' AND octet_length(LEFT(name, 255)) <= 255 AND octet_length({exact}) <= 255 AND ( + LEVENSHTEIN(LOWER(LEFT(name, 255)), LOWER(LEFT({exact}, 255))) < 3 OR + METAPHONE(LOWER(LEFT(name, 255)), 4) = METAPHONE(LOWER(LEFT({exact}, 255)), 4) + )) + OR (name <> '' AND SOUNDEX(LOWER(name)) = SOUNDEX(LOWER({exact}))) + OR (display_name <> '' AND octet_length(LEFT(display_name, 255)) <= 255 AND octet_length({exact}) <= 255 AND ( + LEVENSHTEIN(LOWER(LEFT(display_name, 255)), LOWER(LEFT({exact}, 255))) < 3 OR + METAPHONE(LOWER(LEFT(display_name, 255)), 4) = METAPHONE(LOWER(LEFT({exact}, 255)), 4) + )) + OR (display_name <> '' AND SOUNDEX(LOWER(display_name)) = SOUNDEX(LOWER({exact}))) + ) + ORDER BY + CASE + WHEN LOWER(name) = LOWER({exact}) OR LOWER(display_name) = LOWER({exact}) THEN 0 + WHEN LOWER(name) LIKE LOWER({prefix}) OR LOWER(display_name) LIKE LOWER({prefix}) THEN 1 + WHEN LOWER(name) LIKE LOWER({search}) OR LOWER(display_name) LIKE LOWER({search}) THEN 2 + ELSE LEAST( + CASE WHEN name <> '' AND octet_length(LEFT(name, 255)) <= 255 AND octet_length({exact}) <= 255 + THEN LEVENSHTEIN(LOWER(LEFT(name, 255)), LOWER(LEFT({exact}, 255))) ELSE 999 END, + CASE WHEN display_name <> '' AND octet_length(LEFT(display_name, 255)) <= 255 AND octet_length({exact}) <= 255 + THEN LEVENSHTEIN(LOWER(LEFT(display_name, 255)), LOWER(LEFT({exact}, 255))) ELSE 999 END + ) + 3 + END ASC, + name ASC + LIMIT {limit}""") + .on( + "search" -> searchPattern, + "exact" -> search, + "prefix" -> (search + "%"), + "limit" -> limit + ) + .as(parser.*) + } else { + List.empty + } + } + } + /** * Gets the featured projects * diff --git a/app/org/maproulette/framework/service/ServiceManager.scala b/app/org/maproulette/framework/service/ServiceManager.scala index 3b0bf2537..8cf02ecc8 100644 --- a/app/org/maproulette/framework/service/ServiceManager.scala +++ b/app/org/maproulette/framework/service/ServiceManager.scala @@ -38,7 +38,8 @@ class ServiceManager @Inject() ( teamService: Provider[TeamService], notificationService: Provider[NotificationService], leaderboardService: Provider[LeaderboardService], - taskHistoryService: Provider[TaskHistoryService] + taskHistoryService: Provider[TaskHistoryService], + tileAggregateService: Provider[TileAggregateService] ) { def comment: CommentService = commentService.get() @@ -95,4 +96,6 @@ class ServiceManager @Inject() ( def notification: NotificationService = notificationService.get() def leaderboard: LeaderboardService = leaderboardService.get() + + def tileAggregate: TileAggregateService = tileAggregateService.get() } diff --git a/app/org/maproulette/framework/service/TaskClusterService.scala b/app/org/maproulette/framework/service/TaskClusterService.scala index 0a5184122..928b822c3 100644 --- a/app/org/maproulette/framework/service/TaskClusterService.scala +++ b/app/org/maproulette/framework/service/TaskClusterService.scala @@ -14,7 +14,7 @@ import org.maproulette.framework.psql._ import org.maproulette.framework.psql.filter._ import org.maproulette.framework.repository.TaskClusterRepository import org.maproulette.framework.mixins.{SearchParametersMixin, TaskFilterMixin} -import org.maproulette.session.SearchParameters +import org.maproulette.session.{SearchParameters, SearchLocation} /** * Service layer for TaskCluster @@ -99,6 +99,34 @@ class TaskClusterService @Inject() (repository: TaskClusterRepository) this.repository.queryTaskMarkerDataInBoundingBox(query, limit) } + /** + * Simplified method to get challenge tasks in a bounding box + * + * @param bounds Optional bounding box to search within + * @param challengeIds Optional list of challenge IDs to filter by + * @param paging Pagination settings + * @return Tuple of (total count, list of tasks) + */ + def getChallengeTasksInBounds( + bounds: Option[SearchLocation], + challengeIds: Option[List[Long]], + paging: Paging = Paging(Config.DEFAULT_LIST_SIZE, 0) + ): (Int, List[ClusteredPoint]) = { + this.repository.queryChallengeTasksInBounds( + bounds, + challengeIds, + paging.limit, + paging.limit * paging.page + ) + } + + def getTaskMarkers( + statuses: List[Int], + global: Boolean + ): List[TaskMarker] = { + this.repository.queryTaskMarkers(statuses, global) + } + /** * Builds a query to retrieve tasks within a bounding box, applying search parameters. * @@ -139,6 +167,111 @@ class TaskClusterService @Inject() (repository: TaskClusterRepository) } } + /** + * Retrieves task markers with bounding box filtering + * + * @param statuses List of task status filters + * @param global Whether to include global challenges + * @param boundingBox Search parameters including bounding box + * @param keywords Optional comma-separated list of keywords to filter by + * @param difficulty Optional difficulty level to filter by + * @return List of task markers + */ + def getTaskMarkersWithBoundingBox( + statuses: List[Int], + global: Boolean, + boundingBox: SearchLocation, + keywords: Option[String] = None, + difficulty: Option[Int] = None + ): List[TaskMarker] = { + this.repository.queryTaskMarkersWithBoundingBox( + statuses, + global, + boundingBox, + keywords, + difficulty + ) + } + + /** + * Retrieves task markers with bounding box filtering and overlap detection. + * Groups tasks that share the same location together. + * + * @param statuses List of task status filters + * @param global Whether to include global challenges + * @param boundingBox Search parameters including bounding box + * @param keywords Optional comma-separated list of keywords to filter by + * @param difficulty Optional difficulty level to filter by + * @return Tuple of (single task markers, overlapping task markers) + */ + def getTaskMarkersWithOverlaps( + statuses: List[Int], + global: Boolean, + boundingBox: SearchLocation, + keywords: Option[String] = None, + difficulty: Option[Int] = None + ): (List[TaskMarker], List[OverlappingTaskMarker]) = { + this.repository.queryTaskMarkersWithOverlaps( + statuses, + global, + boundingBox, + keywords, + difficulty + ) + } + + /** + * Retrieves clustered task markers + * + * @param statuses List of task status filters + * @param global Whether to include global challenges + * @param boundingBox Search parameters including bounding box + * @param keywords Optional comma-separated list of keywords to filter by + * @param difficulty Optional difficulty level to filter by + * @return List of task cluster summaries + */ + def getTaskMarkersClustered( + statuses: List[Int], + global: Boolean, + boundingBox: SearchLocation, + keywords: Option[String] = None, + difficulty: Option[Int] = None + ): List[TaskClusterSummary] = { + this.repository.queryTaskMarkersClustered( + statuses, + global, + boundingBox, + keywords, + difficulty + ) + } + + /** + * Counts task markers in the given bounding box + * + * @param statuses List of task status filters + * @param global Whether to include global challenges + * @param boundingBox Search parameters including bounding box + * @param keywords Optional comma-separated list of keywords to filter by + * @param difficulty Optional difficulty level to filter by + * @return Count of task markers + */ + def countTaskMarkers( + statuses: List[Int], + global: Boolean, + boundingBox: SearchLocation, + keywords: Option[String] = None, + difficulty: Option[Int] = None + ): Int = { + this.repository.queryCountTaskMarkers( + statuses, + global, + boundingBox, + keywords, + difficulty + ) + } + /** * Ensures that either a location or bounding geometries are provided in the search parameters. * diff --git a/app/org/maproulette/framework/service/TileAggregateService.scala b/app/org/maproulette/framework/service/TileAggregateService.scala new file mode 100644 index 000000000..98a7e48a3 --- /dev/null +++ b/app/org/maproulette/framework/service/TileAggregateService.scala @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + +package org.maproulette.framework.service + +import javax.inject.{Inject, Singleton} +import org.maproulette.framework.repository.TileAggregateRepository +import org.slf4j.LoggerFactory + +/** + * Service layer for tile-based task aggregation and MVT generation. + * + * Tile building standard: + * - Zoom 0..11: pre-computed grid cells (`tile_cells`). Each display tile is + * a fixed grid of cells; clustering is grid binning, so it is exact and + * identical whether or not filters are applied. + * - Zoom 12: served live from `tasks` as overlap-aware unclustered markers. + * MapLibre overzooms this through z=18+. + * + * Difficulty/global filters at z<12 are answered from the pre-computed + * `counts_by_filter` buckets. Keyword filters cannot be pre-computed, so those + * requests go through an on-the-fly grid-binning query that uses the same cell + * grid — a filtered map therefore clusters identically to an unfiltered one. + * + * Tiles are not spatially filtered server-side: a tile is a pure function of + * (z, x, y) and the difficulty/global/keyword filters, so it stays HTTP + * cacheable. Location filtering (e.g. "only France") is applied client-side by + * highlighting the area, not by mutating tile contents. + */ +@Singleton +class TileAggregateService @Inject() ( + repository: TileAggregateRepository +) { + private val logger = LoggerFactory.getLogger(this.getClass) + + /** Inclusive ceiling of zoom levels the server emits MVT for. */ + val MAX_ZOOM = repository.TASK_ZOOM + + /** + * Get MVT bytes for the given tile. Returns an empty `Array[Byte]` when no + * features match or z is outside the served range, letting the controller + * serve an empty 200 response that MapLibre treats as "no data here". + * + * Routing: + * - z > MAX_ZOOM: empty; MapLibre overzooms the last native tile. + * - z == 12: live `tasks` query (individual / overlap markers). + * - z in 0..11 without keyword filters: pre-computed `tile_cells`. + * - z in 0..11 with keyword filters: on-the-fly grid-binning query. + */ + def getMvtTile( + z: Int, + x: Int, + y: Int, + difficulty: Option[Int] = None, + global: Boolean = false, + keywords: Option[String] = None + ): Array[Byte] = { + if (z < 0 || z > MAX_ZOOM) return Array.empty[Byte] + + val hasKeywords = keywords.exists(_.trim.nonEmpty) + + if (z == repository.TASK_ZOOM) { + repository.getMvtTasksLive(z, x, y, difficulty, global, keywords) + } else if (!hasKeywords) { + repository.getMvtCellsPrecomputed(z, x, y, difficulty, global) + } else { + repository.getMvtCellsLive(z, x, y, difficulty, global, keywords) + } + } + + /** + * Drain the dirty-cell queue. Recomputes affected leaf cells from the base + * tables and rolls the changes up to z=0. Returns the number of leaf cells + * processed. + */ + def rebuildDirtyCells(limit: Int = 512): Int = + repository.rebuildDirtyCells(limit, newestFirst = false) + + /** Full rebuild of the pyramid (initial population / crash recovery). */ + def rebuildAll(): Int = repository.rebuildAll() + + /** Stats for ops / debugging. */ + def getStats(): Map[String, Int] = { + Map( + "totalCells" -> repository.getCellCount(), + "dirtyCells" -> repository.getDirtyCellCount(), + "dirtyQueueLagS" -> repository.getDirtyQueueLagSeconds() + ) + } +} diff --git a/app/org/maproulette/framework/service/UserService.scala b/app/org/maproulette/framework/service/UserService.scala index c483cb8ca..1f7f2811e 100644 --- a/app/org/maproulette/framework/service/UserService.scala +++ b/app/org/maproulette/framework/service/UserService.scala @@ -24,7 +24,7 @@ import org.maproulette.permissions.Permission import org.maproulette.session.SearchParameters import org.maproulette.utils.{Crypto, Utils, Writers} import org.slf4j.LoggerFactory -import play.api.libs.json.{JsString, JsValue, Json} +import play.api.libs.json.{JsObject, JsValue, Json} /** * @author mcuthbert @@ -388,7 +388,7 @@ class UserService @Inject() ( ): Option[User] = { val updateBody = Utils.insertIntoJson(Json.parse("{}"), "settings", Json.toJson(settings)) this.update(id, properties match { - case Some(p) => Utils.insertIntoJson(updateBody, "properties", JsString(p.toString())) + case Some(p) => Utils.insertIntoJson(updateBody, "properties", p) case None => updateBody }, user) } @@ -468,10 +468,15 @@ class UserService @Inject() ( val theme = (value \ "settings" \ "theme") .asOpt[Int] .getOrElse(cachedItem.settings.theme.getOrElse(-1)) - val properties = - (value \ "properties").asOpt[String].getOrElse(cachedItem.properties.getOrElse("{}")) + val properties: JsObject = + (value \ "properties") + .asOpt[JsObject] + .getOrElse(cachedItem.properties.getOrElse(Json.obj())) val customBasemaps = (value \ "settings" \ "customBasemaps").asOpt[List[CustomBasemap]] + val plugins = (value \ "settings" \ "plugins") + .asOpt[String] + .orElse(cachedItem.settings.plugins) // If this user always requires a review, then they are not allowed to change it (except super users) if (user.settings.needsReview.getOrElse(0) == User.REVIEW_MANDATORY) { @@ -510,7 +515,8 @@ class UserService @Inject() ( customBasemaps, Some(seeTagFixSuggestions), Some(disableTaskConfirm), - Some(showPriorityMarkerColors) + Some(showPriorityMarkerColors), + plugins ), properties = Some(properties) ), @@ -830,6 +836,66 @@ class UserService @Inject() ( this.savedObjectsRepository.unsaveChallenge(userId, challengeId) } + /** + * Checks if a challenge is saved (favorited) by a user + * + * @param userId The id of the user + * @param challengeId The id of the challenge + * @param user The user making the actual request + * @return true if the challenge is saved by the user + */ + def isChallengeSaved(userId: Long, challengeId: Long, user: User): Boolean = { + this.permission.hasReadAccess(UserType(), user)(userId) + this.savedObjectsRepository.isChallengeSaved(userId, challengeId) + } + + /** + * Likes a challenge for a user + * + * @param userId The id of the user to like the challenge for + * @param challengeId The id of the challenge to like + * @param user The user making the actual request + */ + def likeChallenge(userId: Long, challengeId: Long, user: User): Unit = { + this.permission.hasWriteAccess(UserType(), user)(userId) + this.savedObjectsRepository.likeChallenge(userId, challengeId) + } + + /** + * "Unlike" a challenge from a users profile + * + * @param userId The id of the user to unlike the challenge from + * @param challengeId The id of the challenge to unlike + * @param user The user making the actual request + */ + def unlikeChallenge(userId: Long, challengeId: Long, user: User): Unit = { + this.permission.hasWriteAccess(UserType(), user)(userId) + this.savedObjectsRepository.unlikeChallenge(userId, challengeId) + } + + /** + * Checks if a challenge is liked by a user + * + * @param userId The id of the user + * @param challengeId The id of the challenge + * @param user The user making the actual request + * @return true if the challenge is liked by the user + */ + def isChallengeLiked(userId: Long, challengeId: Long, user: User): Boolean = { + this.permission.hasReadAccess(UserType(), user)(userId) + this.savedObjectsRepository.isChallengeLiked(userId, challengeId) + } + + /** + * Gets the total like count for a challenge + * + * @param challengeId The id of the challenge + * @return The total number of likes for the challenge + */ + def getChallengeLikeCount(challengeId: Long): Long = { + this.savedObjectsRepository.getChallengeLikeCount(challengeId) + } + /** * Retrieve all the tasks that have been saved * diff --git a/app/org/maproulette/jobs/Scheduler.scala b/app/org/maproulette/jobs/Scheduler.scala index b2e324153..3db5ff8a4 100644 --- a/app/org/maproulette/jobs/Scheduler.scala +++ b/app/org/maproulette/jobs/Scheduler.scala @@ -115,6 +115,13 @@ class Scheduler @Inject() ( Config.KEY_SCHEDULER_UPDATE_CHALLENGE_COMPLETION_INTERVAL ) + schedule( + "rebuildDirtyTileCells", + "Rebuilding Dirty Tile Cells", + 30.seconds, + Config.KEY_SCHEDULER_REBUILD_DIRTY_TILE_CELLS_INTERVAL + ) + scheduleAtTime( "sendCountNotificationDailyEmails", "Sending Count Notification Daily Emails", diff --git a/app/org/maproulette/jobs/SchedulerActor.scala b/app/org/maproulette/jobs/SchedulerActor.scala index 09f37109a..8bdcc4385 100644 --- a/app/org/maproulette/jobs/SchedulerActor.scala +++ b/app/org/maproulette/jobs/SchedulerActor.scala @@ -92,6 +92,43 @@ class SchedulerActor @Inject() ( this.handleArchiveChallenges(action) case RunJob("updateChallengeCompletionMetrics", action) => this.handleUpdateChallengeCompletionMetrics(action) + case RunJob("rebuildDirtyTileCells", action) => + this.rebuildDirtyTileCells(action) + } + + /** Leaf cells recomputed per drain transaction. */ + private val TileDrainBatch = 512 + + /** Hard cap on batches per run, so a single drain cannot run unbounded. */ + private val TileMaxBatchesPerRun = 200 + + /** + * Drains the dirty-cell queue (marked by the task/challenge/project triggers + * in evolution 107), recomputing affected leaf cells from the base tables and + * rolling the changes up the pyramid. Runs on a fixed schedule; see + * Config.KEY_SCHEDULER_REBUILD_DIRTY_TILE_CELLS_INTERVAL. To enable, set: + * osm.scheduler.rebuildDirtyTileCells.interval=FiniteDuration + */ + def rebuildDirtyTileCells(action: String): Unit = { + val tileAggregate = this.serviceManager.tileAggregate + var total = 0 + var batches = 0 + var n = tileAggregate.rebuildDirtyCells(TileDrainBatch) + total += n + while (n >= TileDrainBatch && batches < TileMaxBatchesPerRun) { + n = tileAggregate.rebuildDirtyCells(TileDrainBatch) + total += n + batches += 1 + } + if (total > 0) { + logger.info(s"Scheduled Task '$action': rebuilt $total dirty leaf cells") + } + if (batches >= TileMaxBatchesPerRun) { + logger.warn( + s"Scheduled Task '$action': hit the per-run batch cap ($TileMaxBatchesPerRun); " + + "dirty cells are accumulating faster than the drain can keep up" + ) + } } /** @@ -168,11 +205,11 @@ class SchedulerActor @Inject() ( db.withTransaction { implicit c => val query = - s"""UPDATE challenges + s"""UPDATE challenges SET location = (SELECT ST_Centroid(ST_Collect(ST_Makevalid(location))) FROM tasks WHERE parent_id = ${id}), - bounding = (SELECT ST_Envelope(ST_Buffer((ST_SetSRID(ST_Extent(location), 4326))::geography,2)::geometry) + bounding = (SELECT ST_SetSRID(ST_Extent(location)::geometry, 4326) FROM tasks WHERE parent_id = ${id}), last_updated = NOW(), @@ -876,6 +913,7 @@ class SchedulerActor @Inject() ( logger.warn(s"The KeepRight challenge creation failed. ${f.getMessage}") } } + } object SchedulerActor { diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index 4fa3854a0..ce6bb7da0 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -27,9 +27,10 @@ import org.maproulette.models.dal.mixin.{OwnerMixin, TagDALMixin} import org.maproulette.permissions.Permission import org.maproulette.session.SearchParameters import org.maproulette.utils.Utils +import org.maproulette.framework.psql.SQLUtils import play.api.db.Database import play.api.libs.json.JodaReads._ -import play.api.libs.json.{JsArray, JsString, JsValue, Json} +import play.api.libs.json.{JsArray, JsObject, JsValue, Json} import scala.collection.mutable.ListBuffer import scala.concurrent.Future @@ -165,7 +166,8 @@ class ChallengeDAL @Inject() ( get[Option[Int]]("challenges.completion_percentage") ~ get[Option[Int]]("challenges.tasks_remaining") ~ get[Boolean]("challenges.require_confirmation") ~ - get[Boolean]("challenges.require_reject_reason") map { + get[Boolean]("challenges.require_reject_reason") ~ + get[Option[JsValue]]("challenges.completion_metrics") map { case id ~ name ~ created ~ modified ~ description ~ infoLink ~ ownerId ~ parentId ~ instruction ~ difficulty ~ blurb ~ enabled ~ featured ~ cooperativeType ~ popularity ~ checkin_comment ~ checkin_source ~ overpassql ~ remoteGeoJson ~ overpassTargetType ~ status ~ statusMessage ~ @@ -174,32 +176,7 @@ class ChallengeDAL @Inject() ( exportableProperties ~ osmIdProperty ~ taskBundleIdProperty ~ preferredTags ~ preferredReviewTags ~ limitTags ~ limitReviewTags ~ taskStyles ~ lastTaskRefresh ~ dataOriginDate ~ location ~ bounding ~ requiresLocal ~ deleted ~ isGlobal ~ isArchived ~ reviewSetting ~ datasetUrl ~ taskWidgetLayout ~ - completionPercentage ~ tasksRemaining ~ requireConfirmation ~ requireRejectReason => - val hpr = highPriorityRule match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "{}") => None - case r => r - } - val mpr = mediumPriorityRule match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "{}") => None - case r => r - } - val lpr = lowPriorityRule match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "{}") => None - case r => r - } - val hpb = highPriorityBounds match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "[]") => None - case r => r - } - val mpb = mediumPriorityBounds match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "[]") => None - case r => r - } - val lpb = lowPriorityBounds match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "[]") => None - case r => r - } - + completionPercentage ~ tasksRemaining ~ requireConfirmation ~ requireRejectReason ~ completionMetricsJson => new Challenge( id, name, @@ -227,7 +204,15 @@ class ChallengeDAL @Inject() ( requiresLocal ), ChallengeCreation(overpassql, remoteGeoJson, overpassTargetType), - ChallengePriority(defaultPriority, hpr, mpr, lpr, hpb, mpb, lpb), + ChallengePriority( + defaultPriority, + highPriorityRule, + mediumPriorityRule, + lowPriorityRule, + highPriorityBounds, + mediumPriorityBounds, + lowPriorityBounds + ), ChallengeExtra( defaultZoom, minZoom, @@ -246,7 +231,7 @@ class ChallengeDAL @Inject() ( taskBundleIdProperty, isArchived, reviewSetting, - taskWidgetLayout, + taskWidgetLayout.map(_.as[JsObject]), datasetUrl, None, // systemArchivedAt None, // presets @@ -260,7 +245,7 @@ class ChallengeDAL @Inject() ( location, bounding, completionPercentage, - tasksRemaining + completionMetricsJson.flatMap(_.asOpt[CompletionMetrics]).getOrElse(CompletionMetrics()) ) } } @@ -331,7 +316,8 @@ class ChallengeDAL @Inject() ( get[Option[Int]]("challenges.completion_percentage") ~ get[Option[Int]]("challenges.tasks_remaining") ~ get[Boolean]("challenges.require_confirmation") ~ - get[Boolean]("challenges.require_reject_reason") map { + get[Boolean]("challenges.require_reject_reason") ~ + get[Option[JsValue]]("challenges.completion_metrics") map { case id ~ name ~ created ~ modified ~ description ~ infoLink ~ ownerId ~ parentId ~ instruction ~ difficulty ~ blurb ~ enabled ~ featured ~ cooperativeType ~ popularity ~ checkin_comment ~ checkin_source ~ overpassql ~ remoteGeoJson ~ overpassTargetType ~ @@ -341,32 +327,7 @@ class ChallengeDAL @Inject() ( preferredReviewTags ~ limitTags ~ limitReviewTags ~ taskStyles ~ lastTaskRefresh ~ dataOriginDate ~ location ~ bounding ~ requiresLocal ~ deleted ~ isGlobal ~ virtualParents ~ presets ~ isArchived ~ reviewSetting ~ datasetUrl ~ taskWidgetLayout ~ systemArchivedAt ~ completionPercentage ~ - tasksRemaining ~ requireConfirmation ~ requireRejectReason => - val hpr = highPriorityRule match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "{}") => None - case r => r - } - val mpr = mediumPriorityRule match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "{}") => None - case r => r - } - val lpr = lowPriorityRule match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "{}") => None - case r => r - } - val hpb = highPriorityBounds match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "[]") => None - case r => r - } - val mpb = mediumPriorityBounds match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "[]") => None - case r => r - } - val lpb = lowPriorityBounds match { - case Some(c) if StringUtils.isEmpty(c) || StringUtils.equals(c, "[]") => None - case r => r - } - + tasksRemaining ~ requireConfirmation ~ requireRejectReason ~ completionMetricsJson => new Challenge( id, name, @@ -394,7 +355,15 @@ class ChallengeDAL @Inject() ( requiresLocal ), ChallengeCreation(overpassql, remoteGeoJson, overpassTargetType), - ChallengePriority(defaultPriority, hpr, mpr, lpr, hpb, mpb, lpb), + ChallengePriority( + defaultPriority, + highPriorityRule, + mediumPriorityRule, + lowPriorityRule, + highPriorityBounds, + mediumPriorityBounds, + lowPriorityBounds + ), ChallengeExtra( defaultZoom, minZoom, @@ -413,7 +382,7 @@ class ChallengeDAL @Inject() ( taskBundleIdProperty, isArchived, reviewSetting, - taskWidgetLayout, + taskWidgetLayout.map(_.as[JsObject]), datasetUrl, systemArchivedAt, presets, @@ -426,10 +395,170 @@ class ChallengeDAL @Inject() ( location, bounding, completionPercentage, - tasksRemaining + completionMetricsJson.flatMap(_.asOpt[CompletionMetrics]).getOrElse(CompletionMetrics()) + ) + } + } + + /** + * The row parser for BaseChallenge (flattened structure for API responses) + */ + val baseChallengeParser: RowParser[BaseChallenge] = { + get[Long]("challenges.id") ~ + get[String]("challenges.name") ~ + get[DateTime]("challenges.created") ~ + get[DateTime]("challenges.modified") ~ + get[Option[String]]("challenges.description") ~ + get[Boolean]("deleted") ~ + get[Boolean]("is_global") ~ + get[Boolean]("challenges.require_confirmation") ~ + get[Boolean]("challenges.require_reject_reason") ~ + get[Option[String]]("challenges.info_link") ~ + get[Long]("challenges.owner_id") ~ + get[Long]("challenges.parent_id") ~ + get[String]("challenges.instruction") ~ + get[Int]("challenges.difficulty") ~ + get[Option[String]]("challenges.blurb") ~ + get[Boolean]("challenges.enabled") ~ + get[Boolean]("challenges.featured") ~ + get[Int]("challenges.cooperative_type") ~ + get[Option[Int]]("challenges.popularity") ~ + get[Option[String]]("challenges.checkin_comment") ~ + get[Option[String]]("challenges.checkin_source") ~ + get[Boolean]("challenges.requires_local") ~ + get[Option[String]]("challenges.overpass_ql") ~ + get[Option[String]]("challenges.remote_geo_json") ~ + get[Option[String]]("challenges.overpass_target_type") ~ + get[Int]("challenges.default_priority") ~ + get[Option[String]]("challenges.high_priority_rule") ~ + get[Option[String]]("challenges.medium_priority_rule") ~ + get[Option[String]]("challenges.low_priority_rule") ~ + get[Option[String]]("challenges.high_priority_bounds") ~ + get[Option[String]]("challenges.medium_priority_bounds") ~ + get[Option[String]]("challenges.low_priority_bounds") ~ + get[Int]("challenges.default_zoom") ~ + get[Int]("challenges.min_zoom") ~ + get[Int]("challenges.max_zoom") ~ + get[Boolean]("challenges.updatetasks") ~ + get[Boolean]("challenges.limit_tags") ~ + get[Boolean]("challenges.limit_review_tags") ~ + get[Boolean]("challenges.is_archived") ~ + get[Int]("challenges.review_setting") ~ + get[Option[Int]]("challenges.default_basemap") ~ + get[Option[String]]("challenges.default_basemap_id") ~ + get[Option[String]]("challenges.custom_basemap") ~ + get[Option[String]]("challenges.exportable_properties") ~ + get[Option[String]]("challenges.osm_id_property") ~ + get[Option[String]]("challenges.task_bundle_id_property") ~ + get[Option[JsValue]]("challenges.task_widget_layout") ~ + get[Option[String]]("challenges.task_styles") ~ + get[Option[Int]]("challenges.status") ~ + get[Option[String]]("challenges.status_message") ~ + get[Option[DateTime]]("challenges.last_task_refresh") ~ + get[Option[DateTime]]("challenges.data_origin_date") ~ + get[Option[String]]("locationJSON") ~ + get[Option[String]]("boundingJSON") ~ + get[Option[Int]]("challenges.completion_percentage") ~ + get[Option[JsValue]]("challenges.completion_metrics") map { + case id ~ name ~ created ~ modified ~ description ~ deleted ~ isGlobal ~ requireConfirmation ~ requireRejectReason ~ + infoLink ~ ownerId ~ parentId ~ instruction ~ difficulty ~ blurb ~ enabled ~ featured ~ cooperativeType ~ + popularity ~ checkin_comment ~ checkin_source ~ requiresLocal ~ overpassQL ~ remoteGeoJson ~ overpassTargetType ~ + defaultPriority ~ highPriorityRule ~ mediumPriorityRule ~ lowPriorityRule ~ highPriorityBounds ~ + mediumPriorityBounds ~ lowPriorityBounds ~ defaultZoom ~ minZoom ~ maxZoom ~ updateTasks ~ limitTags ~ + limitReviewTags ~ isArchived ~ reviewSetting ~ defaultBasemap ~ defaultBasemapId ~ customBasemap ~ + exportableProperties ~ osmIdProperty ~ taskBundleIdProperty ~ taskWidgetLayout ~ taskStyles ~ status ~ + statusMessage ~ lastTaskRefresh ~ dataOriginDate ~ location ~ bounding ~ completionPercentage ~ + completionMetricsJson => + val hpr = highPriorityRule.map(Json.parse(_).as[JsObject]) + val mpr = mediumPriorityRule.map(Json.parse(_).as[JsObject]) + val lpr = lowPriorityRule.map(Json.parse(_).as[JsObject]) + val hpb = highPriorityBounds.map(Json.parse(_).as[JsArray]) + val mpb = mediumPriorityBounds.map(Json.parse(_).as[JsArray]) + val lpb = lowPriorityBounds.map(Json.parse(_).as[JsArray]) + val ts = taskStyles.map(Json.parse(_).as[JsArray]) + + new BaseChallenge( + id, + name, + created, + modified, + description, + deleted, + isGlobal, + requireConfirmation, + requireRejectReason, + infoLink, + ownerId, + parentId, + instruction, + difficulty, + blurb, + enabled, + featured, + cooperativeType, + popularity, + checkin_comment.getOrElse(""), + checkin_source.getOrElse(""), + requiresLocal, + overpassQL, + remoteGeoJson, + overpassTargetType, + defaultPriority, + hpr, + mpr, + lpr, + hpb, + mpb, + lpb, + defaultZoom, + minZoom, + maxZoom, + updateTasks, + limitTags, + limitReviewTags, + isArchived, + reviewSetting, + defaultBasemap, + defaultBasemapId, + customBasemap, + exportableProperties, + osmIdProperty, + taskBundleIdProperty, + taskWidgetLayout.map(_.as[JsObject]), + ts, + status, + statusMessage, + lastTaskRefresh, + dataOriginDate, + location.map(Json.parse(_).as[JsObject]), + bounding.map(Json.parse(_).as[JsObject]), + completionPercentage, + completionMetricsJson + .flatMap(_.asOpt[CompletionMetrics]) + .getOrElse(CompletionMetrics()) ) } } + + /** + * Retrieves a BaseChallenge by ID (flattened structure for API responses) + */ + def retrieveBaseChallengeById( + implicit id: Long, + c: Option[Connection] = None + ): Option[BaseChallenge] = { + this.withMRConnection { implicit c => + val query = + s""" + |SELECT c.$retrieveColumns + |FROM challenges c + |WHERE c.id = {id} + """.stripMargin + + SQL(query).on(Symbol("id") -> id).as(this.baseChallengeParser.singleOpt) + } + } + val pointParser: RowParser[ClusteredPoint] = { get[Long]("tasks.id") ~ get[String]("tasks.name") ~ @@ -488,7 +617,7 @@ class ChallengeDAL @Inject() ( parentId, parentName.getOrElse(orParentName.get), point, - JsString(""), + Json.obj(), instruction, DateTime.now(), -1, @@ -583,6 +712,22 @@ class ChallengeDAL @Inject() ( } } + // Normalize legacy encodings of 'no value ("" / "{}" / "[]") that some + // callers (e.g. the Java client) still send. Empty values are stored as + // NULL in the database. + def normalizeNullValues(value: Option[String], emptyMarker: String): Option[String] = + value match { + case Some(v) if StringUtils.isEmpty(v) || StringUtils.equals(v, emptyMarker) => None + case other => other + } + + val highPriorityRule = normalizeNullValues(challenge.priority.highPriorityRule, "{}") + val mediumPriorityRule = normalizeNullValues(challenge.priority.mediumPriorityRule, "{}") + val lowPriorityRule = normalizeNullValues(challenge.priority.lowPriorityRule, "{}") + val highPriorityBounds = normalizeNullValues(challenge.priority.highPriorityBounds, "[]") + val mediumPriorityBounds = normalizeNullValues(challenge.priority.mediumPriorityBounds, "[]") + val lowPriorityBounds = normalizeNullValues(challenge.priority.lowPriorityBounds, "[]") + this.cacheManager.withOptionCaching { () => val insertedChallenge = this.withMRTransaction { implicit c => @@ -594,13 +739,13 @@ class ChallengeDAL @Inject() ( osm_id_property, task_bundle_id_property, last_task_refresh, data_origin_date, preferred_tags, preferred_review_tags, limit_tags, limit_review_tags, task_styles, requires_local, is_archived, review_setting, dataset_url, require_confirmation, require_reject_reason, task_widget_layout) VALUES (${challenge.name}, ${challenge.general.owner}, ${challenge.general.parent}, - ${challenge.general.difficulty}, + ${challenge.general.difficulty}, ${challenge.description}, ${challenge.infoLink}, ${challenge.general.blurb}, ${challenge.general.instruction}, ${challenge.general.enabled}, ${challenge.general.featured}, ${challenge.general.checkinComment}, ${challenge.general.checkinSource}, ${challenge.creation.overpassQL}, ${challenge.creation.remoteGeoJson}, ${challenge.creation.overpassTargetType}, ${challenge.status}, - ${challenge.statusMessage}, ${challenge.priority.defaultPriority}, ${challenge.priority.highPriorityRule}, - ${challenge.priority.mediumPriorityRule}, ${challenge.priority.lowPriorityRule}, ${challenge.priority.highPriorityBounds}, ${challenge.priority.mediumPriorityBounds}, ${challenge.priority.lowPriorityBounds}, ${challenge.extra.defaultZoom}, ${challenge.extra.minZoom}, + ${challenge.statusMessage}, ${challenge.priority.defaultPriority}, ${highPriorityRule}, + ${mediumPriorityRule}, ${lowPriorityRule}, ${highPriorityBounds}, ${mediumPriorityBounds}, ${lowPriorityBounds}, ${challenge.extra.defaultZoom}, ${challenge.extra.minZoom}, ${challenge.extra.maxZoom}, ${challenge.extra.defaultBasemap}, ${challenge.extra.defaultBasemapId}, ${challenge.extra.customBasemap}, ${challenge.extra.updateTasks}, ${challenge.extra.exportableProperties}, ${challenge.extra.osmIdProperty}, ${challenge.extra.taskBundleIdProperty}, ${challenge.lastTaskRefresh.getOrElse(DateTime.now()).toString}::timestamptz, @@ -608,7 +753,7 @@ class ChallengeDAL @Inject() ( ${challenge.extra.preferredTags}, ${challenge.extra.preferredReviewTags}, ${challenge.extra.limitTags}, ${challenge.extra.limitReviewTags}, ${challenge.extra.taskStyles}, ${challenge.general.requiresLocal}, ${challenge.extra.isArchived}, ${challenge.extra.reviewSetting}, ${challenge.extra.datasetUrl}, ${challenge.requireConfirmation}, ${challenge.requireRejectReason}, - ${asJson(challenge.extra.taskWidgetLayout.getOrElse(Json.parse("{}")))} + ${asJson(challenge.extra.taskWidgetLayout.getOrElse(Json.obj()))} ) RETURNING #${this.retrieveColumns}""" .as(this.parser.*) .headOption @@ -861,8 +1006,8 @@ class ChallengeDAL @Inject() ( .getOrElse(cachedItem.requireRejectReason) val taskWidgetLayout = (updates \ "taskWidgetLayout") - .asOpt[JsValue] - .getOrElse(cachedItem.extra.taskWidgetLayout.getOrElse(Json.parse("{}"))) + .asOpt[JsObject] + .getOrElse(cachedItem.extra.taskWidgetLayout.getOrElse(Json.obj())) val presets: List[String] = (updates \ "presets") .asOpt[List[String]] @@ -958,8 +1103,31 @@ class ChallengeDAL @Inject() ( } /** - * Will run through the tasks in batches of 50 and update the priorities based on the rules - * of the challenge + * Reads the live priority distribution for a challenge directly from the tasks table, + * bypassing any DAL caching. Used by callers (priorities endpoint) to verify that a + * recompute actually landed in the DB. + */ + def countTasksByPriority( + challengeId: Long + )(implicit c: Option[Connection] = None): Map[Int, Long] = { + this.withMRConnection { implicit c => + SQL"""SELECT priority, COUNT(*) AS cnt FROM tasks + WHERE parent_id = $challengeId + GROUP BY priority""" + .as((SqlParser.int("priority") ~ SqlParser.long("cnt")).map { case p ~ cnt => (p, cnt) }.*) + .toMap + } + } + + /** + * Recomputes each task's priority from the challenge's current rules and bounds + * using a single SQL UPDATE. Genuine failures raise: a missing challenge throws + * `NotFoundException`, lack of permission throws, and a DB error propagates. + * Returns a `(high, medium, low)` tuple of the *net change* in the task count at + * each priority tier (post-count minus pre-count); positive means tasks were + * promoted into that tier, negative means tasks left it. A `(0, 0, 0)` result + * means the distribution didn't shift (no valid rules/bounds, no matching tasks, + * or perfectly offsetting movements); it is not a failure signal. * * @param user The user executing the request * @param id The id of the challenge @@ -968,7 +1136,7 @@ class ChallengeDAL @Inject() ( def updateTaskPriorities( user: User, overrideValidation: Boolean = false - )(implicit id: Long, c: Option[Connection] = None): Unit = { + )(implicit id: Long, c: Option[Connection] = None): (Int, Int, Int) = { this.permission.hasWriteAccess(ChallengeType(), user) this.withMRConnection { implicit c => // Bypass the challenge cache so freshly-updated priority rules/bounds are @@ -991,8 +1159,25 @@ class ChallengeDAL @Inject() ( // make sure that at least one of the challenges is valid if (overrideValidation || hasRules || hasBounds) { + // Per-tier write counts aren't directly available from the single CASE + // UPDATE in recomputePriorities, so derive them from the change in the + // priority distribution. The receipt's `tasksWritten.X` reports the + // net change in tasks at tier X (positive = promoted into the tier, + // negative = moved out). All-zero deltas means the recompute didn't + // shift the distribution. + val preCounts = this.countTasksByPriority(id) recomputePriorities(challenge) + val postCounts = this.countTasksByPriority(id) this.taskDAL.clearCaches + def delta(p: Int): Int = + (postCounts.getOrElse(p, 0L) - preCounts.getOrElse(p, 0L)).toInt + ( + delta(Challenge.PRIORITY_HIGH), + delta(Challenge.PRIORITY_MEDIUM), + delta(Challenge.PRIORITY_LOW) + ) + } else { + (0, 0, 0) } } } @@ -1173,6 +1358,48 @@ class ChallengeDAL @Inject() ( } } + /** + * Dry-run `updateTaskPriorities`: computes, but does NOT persist, the priority + * that every task in the challenge would receive under the supplied draft + * priority config. Used by the editor preview so the UI can show tier + * membership that is byte-for-byte consistent with what a subsequent save + * would write — including rule-based matches, which the frontend can't + * evaluate because it doesn't ship per-task OSM tags. + */ + def previewTaskPriorities( + user: User, + draft: ChallengePriority + )(implicit id: Long, c: Option[Connection] = None): Map[Long, Int] = { + this.permission.hasWriteAccess(ChallengeType(), user) + this.withMRConnection { implicit c => + val persisted = this._retrieveById(caching = false) match { + case Some(c) => c + case None => + throw new NotFoundException( + s"Could not preview priorities — no challenge with id $id found." + ) + } + // Splice the draft priority config onto a copy of the persisted challenge + // so `task.getTaskPriority` sees the user's in-progress rules/bounds + // while still reading tasks from the live DB. + val draftChallenge = persisted.copy(priority = draft) + val result = scala.collection.mutable.LongMap[Int]() + var pointer = 0 + var currentTasks: List[Task] = List.empty + do { + currentTasks = listChildren(DEFAULT_NUM_CHILDREN_LIST, pointer) + currentTasks.foreach { task => + val p = + try task.getTaskPriority(draftChallenge) + catch { case _: Exception => task.priority } + result.put(task.id, p) + } + pointer += 1 + } while (currentTasks.size >= DEFAULT_NUM_CHILDREN_LIST) + result.toMap + } + } + /** * Lists the children of the parent, override the base functionality and includes the geojson * as part of the query so that it doesn't have to fetch it each and every time. @@ -1192,7 +1419,7 @@ class ChallengeDAL @Inject() ( )(implicit id: Long, c: Option[Connection] = None): List[Task] = { // add a child caching option that will keep a list of children for the parent this.withMRConnection { implicit c => - val geometryParser = this.taskRepository.getTaskParser(this.taskRepository.updateAndRetrieve) + val geometryParser = this.taskRepository.getTaskParser() val offset = page * limit; val query = s"""SELECT ${taskDAL.retrieveColumns} @@ -1208,7 +1435,7 @@ class ChallengeDAL @Inject() ( LIMIT ${this.sqlLimit(limit)} OFFSET {offset}""" SQL(query) .on( - Symbol("ss") -> this.search(searchString), + Symbol("ss") -> SQLUtils.search(searchString), Symbol("id") -> ToParameterValue.apply[Long](p = keyToStatement).apply(id), Symbol("offset") -> offset ) @@ -1216,6 +1443,70 @@ class ChallengeDAL @Inject() ( } } + def getChallengeTaskMarkers( + id: Long + )(implicit c: Option[Connection] = None): ChallengeTaskMarkersResponse = { + this.withMRConnection { implicit c => + val query = + s"""SELECT + tasks.id, + ST_Y(tasks.location) as lat, + ST_X(tasks.location) as lng, + tasks.status, + tasks.priority, + tasks.bundle_id, + l.user_id as locked_by, + ST_ClusterDBSCAN(tasks.location, eps := 0.000001, minpoints := 1) OVER () as cluster_id + FROM tasks + LEFT JOIN locked l ON l.item_id = tasks.id AND l.item_type = 2 + WHERE tasks.parent_id = {id}""" + + val allTasks = + SQL(query) + .on(Symbol("id") -> id) + .as( + (long("id") ~ double("lat") ~ double("lng") ~ int("status") ~ int("priority") ~ get[ + Option[Long] + ]("bundle_id") ~ get[Option[Long]]("locked_by") ~ int( + "cluster_id" + )).map { + case taskId ~ lat ~ lng ~ status ~ priority ~ bundleId ~ lockedBy ~ clusterId => + ( + taskId, + TaskMarkerLocation(lat, lng), + status, + priority, + bundleId, + lockedBy, + clusterId + ) + }.* + ) + + // Group by cluster_id - O(n) + val clusters = allTasks.groupBy(_._7) + + val singleMarkers = scala.collection.mutable.ListBuffer[SingleTaskMarker]() + val overlapMarkers = scala.collection.mutable.ListBuffer[OverlapTaskMarker]() + + clusters.values.foreach { clusterTasks => + if (clusterTasks.length == 1) { + val (taskId, location, status, priority, bundleId, lockedBy, _) = clusterTasks.head + singleMarkers += SingleTaskMarker(taskId, location, status, priority, bundleId, lockedBy) + } else { + val location = clusterTasks.head._2 + val overlappingTaskMarkers = clusterTasks.map { + case (tId, tLoc, tStatus, tPriority, tBundleId, tLockedBy, _) => + SingleTaskMarker(tId, tLoc, tStatus, tPriority, tBundleId, tLockedBy) + }.toList + overlapMarkers += OverlapTaskMarker(location, overlappingTaskMarkers) + } + } + + ChallengeTaskMarkersResponse(singleMarkers.toList, overlapMarkers.toList) + } + } + override def find( searchString: String, limit: Int = Config.DEFAULT_LIST_SIZE, @@ -1249,6 +1540,60 @@ class ChallengeDAL @Inject() ( } } + def search( + search: String, + limit: Int = 25, + onlyEnabled: Boolean = false + )(implicit c: Option[Connection] = None): List[Challenge] = { + this.withMRConnection { implicit c => + val isNumeric = search.matches("^\\d+$") + val searchLong = if (isNumeric) Some(search.toLong) else None + val searchPattern = if (search.nonEmpty) s"%$search%" else "%" + val enabledClause = if (onlyEnabled) " AND c.enabled = true AND p.enabled = true" else "" + + if (isNumeric && searchLong.isDefined) { + SQL(s"""SELECT ${this.retrieveColumns} FROM challenges c + INNER JOIN projects p ON p.id = c.parent_id + WHERE c.deleted = false AND p.deleted = false AND c.id = {id}$enabledClause""") + .on("id" -> searchLong.get) + .as(this.parser.*) + } else if (search.nonEmpty) { + SQL(s"""SELECT ${this.retrieveColumns} FROM challenges c + INNER JOIN projects p ON p.id = c.parent_id + WHERE c.deleted = false AND p.deleted = false$enabledClause + AND ( + LOWER(c.name) LIKE LOWER({search}) + OR (c.name <> '' AND octet_length(LEFT(c.name, 255)) <= 255 AND octet_length({exact}) <= 255 AND ( + LEVENSHTEIN(LOWER(LEFT(c.name, 255)), LOWER(LEFT({exact}, 255))) < 3 OR + METAPHONE(LOWER(LEFT(c.name, 255)), 4) = METAPHONE(LOWER(LEFT({exact}, 255)), 4) + )) + OR (c.name <> '' AND ( + SOUNDEX(LOWER(c.name)) = SOUNDEX(LOWER({exact})) + )) + ) + ORDER BY + CASE + WHEN LOWER(c.name) = LOWER({exact}) THEN 0 + WHEN LOWER(c.name) LIKE LOWER({prefix}) THEN 1 + WHEN LOWER(c.name) LIKE LOWER({search}) THEN 2 + ELSE CASE WHEN c.name <> '' AND octet_length(LEFT(c.name, 255)) <= 255 AND octet_length({exact}) <= 255 + THEN LEVENSHTEIN(LOWER(LEFT(c.name, 255)), LOWER(LEFT({exact}, 255))) ELSE 999 END + 3 + END ASC, + c.name ASC + LIMIT {limit}""") + .on( + "search" -> searchPattern, + "exact" -> search, + "prefix" -> (search + "%"), + "limit" -> limit + ) + .as(this.parser.*) + } else { + List.empty + } + } + } + def listing( projectList: Option[List[Long]] = None, limit: Int = Config.DEFAULT_LIST_SIZE, @@ -1301,7 +1646,7 @@ class ChallengeDAL @Inject() ( LIMIT ${this.sqlLimit(limit)} OFFSET {offset}""" SQL(query) .on( - Symbol("ss") -> this.search(searchString), + Symbol("ss") -> SQLUtils.search(searchString), Symbol("offset") -> ToParameterValue.apply[Int].apply(offset) ) .as(this.parser.*) @@ -2050,6 +2395,32 @@ class ChallengeDAL @Inject() ( } } + /** + * Updates the bounding box for a challenge based on all its tasks. + * This should be called after tasks are built or updated. + * + * @param id The id of the challenge + * @param c an implicit connection + */ + def updateBoundingBox()(implicit id: Long, c: Option[Connection] = None): Unit = { + this.withMRConnection { implicit c => + SQL""" + UPDATE challenges SET bounding = ( + SELECT ST_Envelope(ST_Buffer((ST_SetSRID(ST_Extent(location), 4326))::geography, 2)::geometry) + FROM tasks + WHERE parent_id = $id + AND location IS NOT NULL + ) + WHERE id = $id + AND EXISTS ( + SELECT 1 FROM tasks + WHERE parent_id = $id + AND location IS NOT NULL + ) + """.executeUpdate() + } + } + /** * Resets all the Task instructions for the children of the challenge * @@ -2283,4 +2654,106 @@ class ChallengeDAL @Inject() ( SQL(query).as(taskDAL.parser.*) } } + + /** + * Optimized method to explore challenges with specific filtering + * This is a purpose-built query for the exploreChallenges endpoint + * + * Location filtering is bounding-box based: the client resolves any named place + * (e.g. via Nominatim on the frontend) to a bbox and passes it as `boundingBox`. + * + * @param includeGlobal Whether to include challenges marked as global + * @param boundingBox Optional bounding box to filter by challenge location (left, bottom, right, top) + * @param sortBy Column to sort by (name, created, modified, popularity, difficulty) + * @param limit Maximum number of results to return + * @param offset Number of results to skip for pagination + * @param c Optional database connection + * @return List of challenges matching the criteria + */ + def exploreChallenges( + includeGlobal: Boolean, + boundingBox: Option[(Double, Double, Double, Double)], + sortBy: String, + limit: Int, + offset: Int = 0, + keywords: Option[String] = None, + difficulty: Option[Int] = None + )(implicit c: Option[Connection] = None): List[Challenge] = { + this.withMRConnection { implicit c => + val params = new ListBuffer[NamedParameter]() + var query = + s"""SELECT DISTINCT c.*, ST_AsGeoJSON(c.location) AS locationJSON, ST_AsGeoJSON(c.bounding) AS boundingJSON + FROM challenges c + INNER JOIN projects p ON p.id = c.parent_id""" + + val keywordList = keywords match { + case Some(kws) if kws.trim.nonEmpty => + kws.split(",").map(_.trim.toLowerCase).filter(_.nonEmpty).toList + case _ => List.empty[String] + } + + // Add INNER JOIN for keywords filtering if keywords are provided + if (keywordList.nonEmpty) { + query += " INNER JOIN tags_on_challenges toc ON c.id = toc.challenge_id" + query += " INNER JOIN tags t ON toc.tag_id = t.id" + } + + query += " WHERE c.deleted = false AND c.enabled = true AND c.is_archived = false" + query += " AND p.deleted = false AND p.enabled = true" + + if (!includeGlobal) { + query += " AND c.is_global = false" + } + + // Filter by keywords if provided (bound as parameters to avoid SQL injection) + if (keywordList.nonEmpty) { + val placeholders = keywordList.zipWithIndex.map { + case (kw, i) => + params += NamedParameter(s"kw$i", kw) + s"{kw$i}" + } + query += s" AND LOWER(t.name) IN (${placeholders.mkString(", ")})" + } + + // Filter by difficulty if provided + difficulty match { + case Some(diff) => + params += NamedParameter("difficulty", diff) + query += " AND c.difficulty = {difficulty}" + case None => + } + + boundingBox match { + case Some((left, bottom, right, top)) => + params += NamedParameter("bbLeft", left) + params += NamedParameter("bbBottom", bottom) + params += NamedParameter("bbRight", right) + params += NamedParameter("bbTop", top) + query += " AND ST_Intersects(c.bounding, ST_MakeEnvelope({bbLeft}, {bbBottom}, {bbRight}, {bbTop}, 4326))" + case None => + } + + val orderByClause = sortBy.toLowerCase match { + case "name" => "c.name ASC" + case "created" => "c.created DESC" + case "modified" => "c.modified DESC" + case "popularity" => "c.popularity DESC NULLS LAST" + case "difficulty" => "c.difficulty ASC" + case _ => "c.name ASC" + } + + query += s" ORDER BY $orderByClause" + + if (limit > 0) { + query += s" LIMIT ${this.sqlLimit(limit)}" + } + + if (offset > 0) { + params += NamedParameter("offset", offset) + query += " OFFSET {offset}" + } + + SQL(query).on(params.toSeq: _*).as(this.parser.*) + } + } } diff --git a/app/org/maproulette/models/dal/TaskDAL.scala b/app/org/maproulette/models/dal/TaskDAL.scala index 3ba162513..48730b870 100644 --- a/app/org/maproulette/models/dal/TaskDAL.scala +++ b/app/org/maproulette/models/dal/TaskDAL.scala @@ -11,7 +11,6 @@ import anorm.JodaParameterMetaData._ import anorm.SqlParser._ import anorm._ import javax.inject.{Inject, Provider, Singleton} -import org.apache.commons.lang3.StringUtils import org.joda.time.{DateTime, DateTimeZone} import org.locationtech.jts.geom.Envelope import org.maproulette.Config @@ -70,7 +69,7 @@ class TaskDAL @Inject() ( import scala.concurrent.ExecutionContext.Implicits.global - val parser = this.getTaskParser(this.taskRepository.updateAndRetrieve) + val parser = this.getTaskParser() // The cache manager for that tasks override val cacheManager = this.taskRepository.cacheManager @@ -196,9 +195,11 @@ class TaskDAL @Inject() ( s"progression from ${cachedItem.status.getOrElse(0)} to $status not valid." ) } - val priority = (value \ "priority").asOpt[Int].getOrElse(cachedItem.priority) - val geometries = (value \ "geometries").asOpt[String].getOrElse(cachedItem.geometries) - val cooperativeWorkGeometries = (value \ "cooperativeWork").asOpt[String].getOrElse("") + val priority = (value \ "priority").asOpt[Int].getOrElse(cachedItem.priority) + val geometries: JsObject = + (value \ "geometries").asOpt[JsObject].getOrElse(cachedItem.geometries) + val cooperativeWork: Option[JsObject] = + (value \ "cooperativeWork").asOpt[JsObject] val changesetId = (value \ "changesetId").asOpt[Long].getOrElse(cachedItem.changesetId.getOrElse(-1L)) @@ -241,11 +242,7 @@ class TaskDAL @Inject() ( reviewedAt = reviewedAt ), geometries = geometries, - cooperativeWork = if (StringUtils.isEmpty(cooperativeWorkGeometries)) { - None - } else { - Some(cooperativeWorkGeometries) - }, + cooperativeWork = cooperativeWork, priority = priority, changesetId = Some(changesetId) ), @@ -325,6 +322,10 @@ class TaskDAL @Inject() ( user: User )(implicit id: Long, c: Option[Connection] = None): Option[Task] = { this.permission.hasObjectWriteAccess(element, user) + validateGeoJson(element.geometries) + // Add type: FeatureCollection (some legacy clients omit it, but we want + // to make sure GeoJSONs we store in the database are well formed and valid). + val geoJson = element.geometries + ("type" -> JsString("FeatureCollection")) // get the parent challenge, as we need the priority information val parentChallenge = this.manager.challenge.retrieveById(element.parent) match { case Some(c) => c @@ -342,7 +343,7 @@ class TaskDAL @Inject() ( }(id, true, true) this.withMRTransaction { implicit c => val result = - extractCooperativeWork(element.parent, element.geometries, element.cooperativeWork) + extractCooperativeWork(element.parent, geoJson, element.cooperativeWork) val geometries = result._1 var cooperativeWork = result._2 @@ -426,6 +427,44 @@ class TaskDAL @Inject() ( } } + /** + * Ensure that the given JSON object is a valid and non-empty GeoJSON + * FeatureCollection. Raises an error if not, which will be sent as + * a 4xx response back to the client. + */ + private def validateGeoJson(json: JsObject): Unit = { + (json \ "type").toOption match { + // Some legacy clients omit 'type' on the FeatureCollection; let's be + // nice to them and normalize it for them instead of returning 400 + case None => // ok + case Some(JsString("FeatureCollection")) => // ok + case _ => + throw new InvalidException("Task GeoJSON must have type 'FeatureCollection'") + } + val features = (json \ "features").toOption match { + case Some(JsArray(arr)) => arr + case _ => + throw new InvalidException("Task GeoJSON must contain a 'features' array") + } + if (features.isEmpty) { + throw new InvalidException("Task GeoJSON 'features' array must not be empty") + } + features.zipWithIndex.foreach { + case (feature: JsObject, i) => + (feature \ "geometry").toOption match { + case Some(_: JsObject) => // ok + case _ => + throw new InvalidException( + s"Task GeoJSON feature at index $i must have a 'geometry' field" + ) + } + case (_, i) => + throw new InvalidException( + s"Task GeoJSON feature at index $i must be a JSON object" + ) + } + } + /** * Function that extracts the cooperativeWork from the geometries * @@ -435,20 +474,19 @@ class TaskDAL @Inject() ( */ private def extractCooperativeWork( parentId: Long, - geometries: String, - cooperativeWork: Option[String] + geometries: JsObject, + cooperativeWork: Option[JsObject] )( implicit c: Option[Connection] = None ): (String, Option[String]) = { this.withMRTransaction { implicit c => - var cooperativeWorkJson = cooperativeWork + var cooperativeWorkJson: Option[String] = cooperativeWork.map(Json.stringify) - val geoJson = Json.parse(geometries) - var workMatch = (geoJson \\ "cooperativeWork") + var workMatch = (geometries \\ "cooperativeWork") if (workMatch.isEmpty) { // Check to see if our cooperative work JSON was changed into a string due // to being a feature property (which are always converted to strings) - val parentMatch = (geoJson \\ "maproulette") + val parentMatch = (geometries \\ "maproulette") if (!parentMatch.isEmpty) { workMatch = (Json.parse(Utils.unescapeStringifiedJSON(parentMatch.head.toString())) \\ "cooperativeWork") } @@ -458,10 +496,10 @@ class TaskDAL @Inject() ( cooperativeWorkJson = Some(workMatch.head.toString()) } - val attachments = (geoJson \ "attachments").toOption + val attachments = (geometries \ "attachments").toOption val mrTransformer = (__ \ "properties" \ "maproulette").json.prune val extractedGeometries = JsArray( - (geoJson \ "features") + (geometries \ "features") .as[JsArray] .value .map { @@ -891,7 +929,9 @@ class TaskDAL @Inject() ( true } else { val feature = - GeoJSONFactory.create(task.geometries).asInstanceOf[FeatureCollection] + GeoJSONFactory + .create(Json.stringify(task.geometries)) + .asInstanceOf[FeatureCollection] val reader = new GeoJSONReader() val envelope = new Envelope() feature.getFeatures.foreach(f => { @@ -998,6 +1038,7 @@ class TaskDAL @Inject() ( LEFT OUTER JOIN task_review ON task_review.task_id = tasks.id WHERE tasks.id > $currentTaskId AND tasks.parent_id = $parentId AND status IN ({statusList}) + AND NOT tasks.archived ORDER BY tasks.id ASC LIMIT 1""" val slist = statusList.getOrElse(Task.statusMap.keys.toSeq) match { case Nil => Task.statusMap.keys.toSeq @@ -1012,6 +1053,7 @@ class TaskDAL @Inject() ( LEFT OUTER JOIN task_review ON task_review.task_id = tasks.id WHERE tasks.parent_id = $parentId AND status IN ({statusList}) + AND NOT tasks.archived ORDER BY tasks.id ASC LIMIT 1""" SQL(loopQuery).on(Symbol("statusList") -> slist).as(lp.*).headOption } @@ -1042,6 +1084,7 @@ class TaskDAL @Inject() ( LEFT OUTER JOIN task_review ON task_review.task_id = tasks.id WHERE tasks.id < $currentTaskId AND tasks.parent_id = $parentId AND status IN ({statusList}) + AND NOT tasks.archived ORDER BY tasks.id DESC LIMIT 1""" val slist = statusList.getOrElse(Task.statusMap.keys.toSeq) match { case Nil => Task.statusMap.keys.toSeq @@ -1057,6 +1100,7 @@ class TaskDAL @Inject() ( LEFT OUTER JOIN task_review ON task_review.task_id = tasks.id WHERE tasks.parent_id = $parentId AND status IN ({statusList}) + AND NOT tasks.archived ORDER BY tasks.id DESC LIMIT 1""" SQL(loopQuery).on(Symbol("statusList") -> slist).as(lp.*).headOption } @@ -1165,7 +1209,8 @@ class TaskDAL @Inject() ( } val whereClause = new StringBuilder(s"""WHERE tasks.parent_id = $challengeId AND (l.id IS NULL OR l.user_id = ${user.id}) AND - tasks.status IN ({statusList}) + tasks.status IN ({statusList}) AND + NOT tasks.archived """) parameters += (Symbol("statusList") -> ToParameterValue .apply[List[Int]] @@ -1261,10 +1306,10 @@ class TaskDAL @Inject() ( parameters ++= addChallengeTagMatchingToQuery(params, whereClause, joinClause) parameters ++= addSearchToQuery(params, whereClause) - //add a where clause that just makes sure that any random challenge retrieved actually has some tasks in it + //add a where clause that just makes sure that any random challenge retrieved actually has some non-archived tasks in it appendInWhereClause( whereClause, - "1 = (SELECT 1 FROM tasks WHERE parent_id = c.id LIMIT 1)" + "1 = (SELECT 1 FROM tasks WHERE parent_id = c.id AND NOT archived LIMIT 1)" ) val query = @@ -1305,6 +1350,7 @@ class TaskDAL @Inject() ( tasks.parent_id = $challengeId AND (l.id IS NULL ${selfLockedClause}) AND tasks.status IN (0, 3, 6) AND + NOT tasks.archived AND NOT tasks.id IN ( SELECT task_id FROM status_actions WHERE osm_user_id = ${user.osmProfile.id} AND created >= NOW() - '1 hour'::INTERVAL) @@ -1484,20 +1530,6 @@ class TaskDAL @Inject() ( } } - /** - * A temporary solution that will allow us to lazy update the geojson data - * - * @param taskId The identifier of the task - */ - def updateAndRetrieve( - taskId: Long, - geojson: Option[String], - location: Option[String], - cooperativeWork: Option[String] - )(implicit c: Option[Connection] = None): (String, Option[String], Option[String]) = { - this.taskRepository.updateAndRetrieve(taskId, geojson, location, cooperativeWork) - } - case class TaskSummary( taskId: Long, parent: Long, @@ -1521,6 +1553,58 @@ class TaskDAL @Inject() ( isBundlePrimary: Option[Boolean] ) + /** + * Searches for tasks by name using ILIKE. Returns lightweight JSON results + * (no geometry data) for performance on large task tables. + * + * @param searchString The string to search for in task names + * @param limit The maximum number of results to return + * @return A list of JsObjects with task id, name, status, and parent challenge info + */ + def search( + searchString: String, + limit: Int = 25 + )(implicit c: Option[Connection] = None): List[JsObject] = { + if (searchString.isEmpty) { + List.empty + } else { + this.withMRConnection { implicit c => + val searchPattern = s"%${searchString.replace("'", "''")}%" + val query = + """SELECT t.id, t.name, t.status, t.parent_id, + c.name AS challenge_name + FROM tasks t + INNER JOIN challenges c ON c.id = t.parent_id + INNER JOIN projects p ON p.id = c.parent_id + WHERE c.deleted = false AND p.deleted = false + AND t.name ILIKE {search} + ORDER BY t.name ASC + LIMIT {limit}""" + SQL(query) + .on( + "search" -> searchPattern, + "limit" -> limit + ) + .as( + (get[Long]("id") ~ + get[String]("name") ~ + get[Option[Int]]("status") ~ + get[Long]("parent_id") ~ + get[String]("challenge_name")).map { + case id ~ name ~ status ~ parentId ~ challengeName => + Json.obj( + "id" -> id, + "name" -> name, + "status" -> status, + "parent" -> parentId, + "challengeName" -> challengeName + ) + }.* + ) + } + } + } + } object TaskDAL { diff --git a/app/org/maproulette/models/dal/VirtualChallengeDAL.scala b/app/org/maproulette/models/dal/VirtualChallengeDAL.scala index 0b3032b93..57ed4d655 100644 --- a/app/org/maproulette/models/dal/VirtualChallengeDAL.scala +++ b/app/org/maproulette/models/dal/VirtualChallengeDAL.scala @@ -22,7 +22,7 @@ import org.maproulette.permissions.Permission import org.maproulette.session.{SearchLocation, SearchParameters} import play.api.db.Database import play.api.libs.json.JodaReads._ -import play.api.libs.json.{JsString, JsValue, Json} +import play.api.libs.json.{JsValue, Json} import org.maproulette.framework.mixins.Locking import org.maproulette.framework.repository.RepositoryMixin @@ -562,7 +562,7 @@ class VirtualChallengeDAL @Inject() ( -1, "", point, - JsString(""), + Json.obj(), instruction, DateTime.now(), -1, diff --git a/app/org/maproulette/models/utils/ChallengeFormatters.scala b/app/org/maproulette/models/utils/ChallengeFormatters.scala index b5692868d..91c1e8453 100644 --- a/app/org/maproulette/models/utils/ChallengeFormatters.scala +++ b/app/org/maproulette/models/utils/ChallengeFormatters.scala @@ -6,11 +6,13 @@ package org.maproulette.models.utils import org.joda.time.DateTime import org.maproulette.framework.model.{ + BaseChallenge, Challenge, ChallengeCreation, ChallengeExtra, ChallengeGeneral, - ChallengePriority + ChallengePriority, + CompletionMetrics } import org.maproulette.utils.Utils import org.maproulette.utils.Utils.{jsonReads, jsonWrites} @@ -84,7 +86,7 @@ trait ChallengeWrites extends DefaultWrites { } } - implicit val challengeWrites: Writes[Challenge] = ( + private val challengeFieldsWrites: Writes[Challenge] = ( (JsPath \ "id").write[Long] and (JsPath \ "name").write[String] and (JsPath \ "created").write[DateTime] and @@ -106,8 +108,13 @@ trait ChallengeWrites extends DefaultWrites { (JsPath \ "location").writeNullable[String](new jsonWrites("location")) and (JsPath \ "bounding").writeNullable[String](new jsonWrites("bounding")) and (JsPath \ "completionPercentage").writeNullable[Int] and - (JsPath \ "tasksRemaining").writeNullable[Int] + (JsPath \ "completionMetrics").write[CompletionMetrics] )(unlift(Challenge.unapply)) + + // completionMetrics is read straight from the `completion_metrics` JSONB + // column (see ChallengeDAL parser), so every endpoint returns complete and + // accurate per-status counts rather than synthesized values. + implicit val challengeWrites: Writes[Challenge] = challengeFieldsWrites } trait ChallengeReads extends DefaultReads { @@ -162,13 +169,13 @@ trait ChallengeReads extends DefaultReads { reviewSetting = (jsonWithExtras \ "reviewSetting") .asOpt[Int] .getOrElse(Challenge.REVIEW_SETTING_NOT_REQUIRED), - taskWidgetLayout = (jsonWithExtras \ "taskWidgetLayout").asOpt[JsValue], + taskWidgetLayout = (jsonWithExtras \ "taskWidgetLayout").asOpt[JsObject], datasetUrl = (jsonWithExtras \ "datasetUrl").asOpt[String], systemArchivedAt = (jsonWithExtras \ "systemArchivedAt").asOpt[DateTime], presets = (jsonWithExtras \ "presets").asOpt[List[String]], requireConfirmation = (jsonWithExtras \ "requireConfirmation").asOpt[Boolean].getOrElse(false), - mrTagMetrics = (jsonWithExtras \ "mrTagMetrics").asOpt[JsValue] + mrTagMetrics = (jsonWithExtras \ "mrTagMetrics").asOpt[JsObject] ) ) } catch { @@ -199,6 +206,79 @@ trait ChallengeReads extends DefaultReads { (JsPath \ "location").readNullable[String](new jsonReads("location")) and (JsPath \ "bounding").readNullable[String](new jsonReads("bounding")) and (JsPath \ "completionPercentage").readNullable[Int] and - (JsPath \ "tasksRemaining").readNullable[Int] + ((JsPath \ "completionMetrics").read[CompletionMetrics] or Reads.pure(CompletionMetrics())) )(Challenge.apply _) } + +/** + * JSON formatters for BaseChallenge (flattened structure) + */ +trait BaseChallengeWrites extends DefaultWrites { + implicit val baseChallengeWrites: Writes[BaseChallenge] = new Writes[BaseChallenge] { + def writes(bc: BaseChallenge): JsValue = { + val baseFields: Seq[(String, JsValue)] = Seq( + "id" -> JsNumber(bc.id), + "name" -> JsString(bc.name), + "created" -> Json.toJson(bc.created), + "modified" -> Json.toJson(bc.modified), + "deleted" -> JsBoolean(bc.deleted), + "isGlobal" -> JsBoolean(bc.isGlobal), + "requireConfirmation" -> JsBoolean(bc.requireConfirmation), + "requireRejectReason" -> JsBoolean(bc.requireRejectReason), + "owner" -> JsNumber(bc.owner), + "parent" -> JsNumber(bc.parent), + "instruction" -> JsString(bc.instruction), + "difficulty" -> JsNumber(bc.difficulty), + "enabled" -> JsBoolean(bc.enabled), + "featured" -> JsBoolean(bc.featured), + "cooperativeType" -> JsNumber(bc.cooperativeType), + "checkinComment" -> JsString(bc.checkinComment), + "checkinSource" -> JsString(bc.checkinSource), + "requiresLocal" -> JsBoolean(bc.requiresLocal), + "defaultPriority" -> JsNumber(bc.defaultPriority), + "defaultZoom" -> JsNumber(bc.defaultZoom), + "minZoom" -> JsNumber(bc.minZoom), + "maxZoom" -> JsNumber(bc.maxZoom), + "updateTasks" -> JsBoolean(bc.updateTasks), + "limitTags" -> JsBoolean(bc.limitTags), + "limitReviewTags" -> JsBoolean(bc.limitReviewTags), + "isArchived" -> JsBoolean(bc.isArchived), + "reviewSetting" -> JsNumber(bc.reviewSetting), + "completionMetrics" -> Json.toJson(bc.completionMetrics) + ) + + val optionFields: Seq[Option[(String, JsValue)]] = Seq( + bc.description.map(v => "description" -> JsString(v)), + bc.infoLink.map(v => "infoLink" -> JsString(v)), + bc.blurb.map(v => "blurb" -> JsString(v)), + bc.popularity.map(v => "popularity" -> JsNumber(v)), + bc.overpassQL.map(v => "overpassQL" -> JsString(v)), + bc.remoteGeoJson.map(v => "remoteGeoJson" -> JsString(v)), + bc.overpassTargetType.map(v => "overpassTargetType" -> JsString(v)), + bc.highPriorityRule.map(v => "highPriorityRule" -> v), + bc.mediumPriorityRule.map(v => "mediumPriorityRule" -> v), + bc.lowPriorityRule.map(v => "lowPriorityRule" -> v), + bc.highPriorityBounds.map(v => "highPriorityBounds" -> v), + bc.mediumPriorityBounds.map(v => "mediumPriorityBounds" -> v), + bc.lowPriorityBounds.map(v => "lowPriorityBounds" -> v), + bc.defaultBasemap.map(v => "defaultBasemap" -> JsNumber(v)), + bc.defaultBasemapId.map(v => "defaultBasemapId" -> JsString(v)), + bc.customBasemap.map(v => "customBasemap" -> JsString(v)), + bc.exportableProperties.map(v => "exportableProperties" -> JsString(v)), + bc.osmIdProperty.map(v => "osmIdProperty" -> JsString(v)), + bc.taskBundleIdProperty.map(v => "taskBundleIdProperty" -> JsString(v)), + bc.taskWidgetLayout.map(v => "taskWidgetLayout" -> v), + bc.taskStyles.map(v => "taskStyles" -> v), + bc.status.map(v => "status" -> JsNumber(v)), + bc.statusMessage.map(v => "statusMessage" -> JsString(v)), + bc.lastTaskRefresh.map(dt => "lastTaskRefresh" -> Json.toJson(dt)), + bc.dataOriginDate.map(dt => "dataOriginDate" -> Json.toJson(dt)), + bc.location.map(v => "location" -> v), + bc.bounding.map(v => "bounding" -> v), + bc.completionPercentage.map(v => "completionPercentage" -> JsNumber(v)) + ) + + JsObject(baseFields ++ optionFields.flatten) + } + } +} diff --git a/app/org/maproulette/provider/ChallengeProvider.scala b/app/org/maproulette/provider/ChallengeProvider.scala index 90f308cd5..515397e6f 100644 --- a/app/org/maproulette/provider/ChallengeProvider.scala +++ b/app/org/maproulette/provider/ChallengeProvider.scala @@ -109,6 +109,7 @@ class ChallengeProvider @Inject() ( challenge.id ) this.challengeDAL.markTasksRefreshed()(challenge.id) + this.challengeDAL.updateBoundingBox()(challenge.id) } } else { this.createTasksFromJson(user, challenge, value) @@ -117,6 +118,7 @@ class ChallengeProvider @Inject() ( //we need to reapply task priority rules since task locations were updated Future { this.challengeDAL.updateTaskPriorities(user)(challenge.id) + this.challengeDAL.updateBoundingBox()(challenge.id) } }.recover { case e: Exception => @@ -261,6 +263,7 @@ class ChallengeProvider @Inject() ( ) this.challengeDAL.markTasksRefreshed()(challenge.id) + this.challengeDAL.updateBoundingBox()(challenge.id) } } else { this.createTasksFromFeatures(user, challenge, Json.parse(resp.body)) @@ -281,12 +284,14 @@ class ChallengeProvider @Inject() ( //we need to reapply task priority rules since task locations were updated Future { this.challengeDAL.updateTaskPriorities(user)(challenge.id) + this.challengeDAL.updateBoundingBox()(challenge.id) } } case Failure(f) => if (fileNumber > 1) { // todo need to figure out if actual failure or if not finding the next file this.challengeDAL.update(Json.obj("status" -> Challenge.STATUS_READY), user)(challenge.id) + this.challengeDAL.updateBoundingBox()(challenge.id) } else { this.challengeDAL.update( Json.obj("status" -> Challenge.STATUS_FAILED, "StatusMessage" -> f.getMessage), @@ -439,6 +444,7 @@ class ChallengeProvider @Inject() ( this.challengeDAL.update(Json.obj("status" -> Challenge.STATUS_READY), user)(parent.id) this.challengeDAL.markTasksRefreshed()(parent.id) + this.challengeDAL.updateBoundingBox()(parent.id) if (single) { this.createNewTask(user, taskNameFromJsValue(jsonData, parent), parent, jsonData) match { case Some(t) => List(t) @@ -668,6 +674,7 @@ class ChallengeProvider @Inject() ( challenge.id ) this.challengeDAL.markTasksRefreshed(true)(challenge.id) + this.challengeDAL.updateBoundingBox()(challenge.id) // If no tasks were created by this overpass query or all tasks are // fixed, then we need to update the status to finished. this.challengeDAL.updateFinishedStatus(true, user = user)(challenge.id) @@ -685,18 +692,47 @@ class ChallengeProvider @Inject() ( } } } else { + // Handle non-OK responses (timeouts, errors, etc.) + val errorMessage = extractOverpassErrorMessage(result.body, result.status) + val statusMessage = result.status match { + case 504 | 502 => // Gateway Timeout or Bad Gateway + "The Overpass API server is too busy or timed out. Please try again later or reduce the scope of your query." + case 429 => // Too Many Requests + "Too many requests to the Overpass API. Please wait a moment and try again." + case _ => + errorMessage + } + this.challengeDAL.update( Json.obj( "status" -> Challenge.STATUS_FAILED, - "statusMessage" -> s"${result.statusText}:${result.body}" + "statusMessage" -> statusMessage ), user )(challenge.id) - throw new InvalidException(s"${result.statusText}: ${result.body}") + throw new InvalidException(s"${result.statusText}: $statusMessage") } case Failure(f) => + val errorMessage = f match { + case e: java.util.concurrent.TimeoutException => + "The Overpass API request timed out. The server may be too busy. Please try again later or reduce the scope of your query." + case e: java.net.SocketTimeoutException => + "The Overpass API request timed out. The server may be too busy. Please try again later or reduce the scope of your query." + case e: java.net.ConnectException => + "Failed to connect to the Overpass API. Please check your network connection and try again." + case e: Exception => + val msg = e.getMessage + if (msg != null && (msg.contains("timeout") || msg.contains("Timeout"))) { + "The Overpass API request timed out. The server may be too busy. Please try again later or reduce the scope of your query." + } else { + s"Overpass API error: ${msg}" + } + case _ => + if (f.getMessage != null) f.getMessage + else "Unknown error occurred while contacting the Overpass API" + } this.challengeDAL.update( - Json.obj("status" -> Challenge.STATUS_FAILED, "statusMessage" -> f.getMessage), + Json.obj("status" -> Challenge.STATUS_FAILED, "statusMessage" -> errorMessage), user )(challenge.id) throw f @@ -720,7 +756,7 @@ class ChallengeProvider @Inject() ( user, name, parent, - Task(-1, name, DateTime.now(), DateTime.now(), parent.id, Some(""), None, json.toString) + Task(-1, name, DateTime.now(), DateTime.now(), parent.id, Some(""), None, json.as[JsObject]) ) } @@ -739,19 +775,17 @@ class ChallengeProvider @Inject() ( parent.id, Some(""), None, - Json - .obj( - "type" -> "FeatureCollection", - "features" -> Json.arr( - Json.obj( - "id" -> name, - "type" -> "Feature", - "geometry" -> geometry, - "properties" -> properties - ) + Json.obj( + "type" -> "FeatureCollection", + "features" -> Json.arr( + Json.obj( + "id" -> name, + "type" -> "Feature", + "geometry" -> geometry, + "properties" -> properties ) ) - .toString + ) ) this._createNewTask(user, name, parent, newTask) } @@ -866,4 +900,46 @@ class ChallengeProvider @Inject() ( */ private def normalizeRFC7464Sequence(line: String): String = line.replaceAll(s"^${RS}+", "") + + /** + * Extracts a user-friendly error message from Overpass API error responses. + * Handles both XML/HTML error responses and plain text errors. + * + * @param body The response body from the Overpass API + * @param status The HTTP status code + * @return A user-friendly error message + */ + private def extractOverpassErrorMessage(body: String, status: Int): String = { + // Check if the response is XML/HTML (common for Overpass errors) + if (body.contains(" + val errorText = m.group(1).trim + // Clean up common Overpass error messages + if (errorText.contains("timeout")) { + "The Overpass API server timed out. The server may be too busy. Please try again later or reduce the scope of your query." + } else if (errorText.contains("too busy")) { + "The Overpass API server is too busy to handle your request. Please try again later." + } else { + errorText + } + case None => + // Fallback: check for common error patterns + if (body.contains("timeout") || body.contains("too busy")) { + "The Overpass API server timed out or is too busy. Please try again later or reduce the scope of your query." + } else { + s"Overpass API error (HTTP $status). Please check your query and try again." + } + } + } else { + // Plain text or JSON error + if (body.length > 500) { + body.substring(0, 500) + "..." + } else { + body + } + } + } } diff --git a/app/org/maproulette/provider/KeepRightProvider.scala b/app/org/maproulette/provider/KeepRightProvider.scala index 4b0916cdd..a63056391 100644 --- a/app/org/maproulette/provider/KeepRightProvider.scala +++ b/app/org/maproulette/provider/KeepRightProvider.scala @@ -18,6 +18,7 @@ import org.maproulette.utils.Utils import org.slf4j.LoggerFactory import play.api.db.Database import play.api.http.Status +import play.api.libs.json.{JsObject, Json} import play.api.libs.ws.WSClient import scala.concurrent.duration.Duration @@ -177,16 +178,19 @@ class KeepRightProvider @Inject() ( this.withMRTransaction { implicit c => val totalTasks = errors._2.map(kpError => { - val geometry = - s""" - {"type":"FeatureCollection", - "features":[{ - "geometry":{"type":"Point","coordinates":[${kpError.lat}, ${kpError.lon}]}, - "type":"Feature", - "properties":{} - }] - } - """ + val geometry: JsObject = Json.obj( + "type" -> "FeatureCollection", + "features" -> Json.arr( + Json.obj( + "geometry" -> Json.obj( + "type" -> "Point", + "coordinates" -> Json.arr(kpError.lat, kpError.lon) + ), + "type" -> "Feature", + "properties" -> Json.obj() + ) + ) + ) this.taskDAL.mergeUpdate( Task( -1, diff --git a/build.sbt b/build.sbt index dfa971923..415599fed 100644 --- a/build.sbt +++ b/build.sbt @@ -90,7 +90,7 @@ libraryDependencies ++= Seq( // NOTE: The swagger-ui package is used to obtain the static distribution of swagger-ui, the files included at runtime // and are served by the webserver at route '/assets/lib/swagger-ui/'. We have a few customized swagger files in dir // 'public/swagger'. - "org.webjars" % "swagger-ui" % "5.10.3", + "org.webjars" % "swagger-ui" % "5.32.5", "org.playframework.anorm" %% "anorm" % "2.7.0", "org.playframework.anorm" %% "anorm-postgres" % "2.7.0", "org.postgresql" % "postgresql" % "42.7.3", // https://github.com/pgjdbc/pgjdbc/releases @@ -227,6 +227,7 @@ val routeFiles: Seq[String] = Seq( "follow.api", "leaderboard.api", "service.api", + "search.api", "v2.api" ) diff --git a/conf/application.conf b/conf/application.conf index aebf987cd..5fcbca0cb 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -270,6 +270,13 @@ maproulette { interval = "20 minutes" } + # Drains the dirty-cell queue (marked by the task/challenge/project triggers) + # and keeps the pre-computed tile pyramid fresh. Set the interval to an empty + # string to disable background tile work for this instance. + rebuildDirtyTileCells { + interval = "30 seconds" + } + notifications { immediateEmail { interval = "1 minute" diff --git a/conf/evolutions/default/107.sql b/conf/evolutions/default/107.sql new file mode 100644 index 000000000..d8862b992 --- /dev/null +++ b/conf/evolutions/default/107.sql @@ -0,0 +1,455 @@ +# --- MapRoulette Scheme + +# --- !Ups +-- ============================================================================= +-- Tile system: hierarchical grid-binned task aggregation for the explore map. +-- +-- Design +-- ------ +-- * Display zoom 0..11 are PRE-COMPUTED. Each zoom is aggregated onto a fixed +-- grid of cells: a cell at display zoom z is exactly a slippy tile at zoom +-- z + CELL_BITS (CELL_BITS = 4 -> 16x16 = 256 cells per display tile, each +-- ~256 MVT pixels). Because the grid is hierarchical, a cell at zoom z is +-- EXACTLY the union of its four children at zoom z+1, so roll-up is plain +-- additive summation -- exact, deterministic, no clustering algorithm. +-- * Display zoom 12 is served live from `tasks` (see TileAggregateRepository) +-- as individual / overlap-deduped task markers, and is NOT stored here. +-- * Display zoom 13+ is overzoomed client-side from z=12. +-- +-- Per-cell we store only additive quantities (task_count, sum_lat, sum_lng, +-- counts_by_filter). The emitted centroid is sum_lat/task_count, sum_lng/ +-- task_count -- itself additive, so roll-up stays exact. +-- +-- Updates +-- ------- +-- * Triggers on `tasks` / `challenges` mark affected LEAF cells (z=11) dirty +-- in `tile_dirty_cells`. +-- * A drain (rebuild_dirty_cells), run on a fixed schedule by the backend +-- (see SchedulerActor `rebuildDirtyTileCells`), recomputes each dirty leaf +-- cell from the base tables -- AUTHORITATIVE, so it is immune to the races a +-- pure delta scheme suffers -- then rolls the change up z=10..0 by summation. +-- * The drain holds a single global advisory lock, so only one drainer +-- touches the pyramid at a time and concurrent rollups never race. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Storage +-- --------------------------------------------------------------------------- + +-- Pre-computed grid cells for display zoom 0..11. +-- (cx, cy) are slippy-tile coordinates at zoom (z + 4). +CREATE TABLE IF NOT EXISTS tile_cells ( + z SMALLINT NOT NULL, + cx INTEGER NOT NULL, + cy INTEGER NOT NULL, + task_count INTEGER NOT NULL, + sum_lat DOUBLE PRECISION NOT NULL, + sum_lng DOUBLE PRECISION NOT NULL, + counts_by_filter JSONB NOT NULL DEFAULT '{}'::jsonb, + last_updated TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (z, cx, cy) +);; + +-- Queue of leaf cells (z=11 grid, i.e. slippy zoom 15) awaiting recompute. +CREATE TABLE IF NOT EXISTS tile_dirty_cells ( + cx INTEGER NOT NULL, + cy INTEGER NOT NULL, + marked_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (cx, cy) +);; +CREATE INDEX IF NOT EXISTS idx_tile_dirty_cells_marked_at ON tile_dirty_cells (marked_at);; + +-- --------------------------------------------------------------------------- +-- Coordinate helpers +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION lng_to_tile_x(lng DOUBLE PRECISION, zoom INTEGER) RETURNS INTEGER AS $$ + SELECT FLOOR((lng + 180.0) / 360.0 * (1 << zoom))::INTEGER +$$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE;; + +CREATE OR REPLACE FUNCTION lat_to_tile_y(lat DOUBLE PRECISION, zoom INTEGER) RETURNS INTEGER AS $$ + SELECT FLOOR((1.0 - LN(TAN(RADIANS(GREATEST(-85.0511, LEAST(85.0511, lat)))) + + 1.0 / COS(RADIANS(GREATEST(-85.0511, LEAST(85.0511, lat))))) / PI()) / 2.0 * (1 << zoom))::INTEGER +$$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE;; + +-- Lon/lat (EPSG:4326) envelope of a slippy tile. Used to drive GiST-indexed +-- bbox prefilters (t.location && tile_envelope_4326(...)). +CREATE OR REPLACE FUNCTION tile_envelope_4326(p_tz INTEGER, p_tx INTEGER, p_ty INTEGER) +RETURNS geometry AS $$ + SELECT ST_MakeEnvelope( + p_tx::double precision / (1 << p_tz) * 360.0 - 180.0, + DEGREES(ATAN(SINH(PI() * (1.0 - 2.0 * (p_ty + 1)::double precision / (1 << p_tz))))), + (p_tx + 1)::double precision / (1 << p_tz) * 360.0 - 180.0, + DEGREES(ATAN(SINH(PI() * (1.0 - 2.0 * p_ty::double precision / (1 << p_tz))))), + 4326) +$$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE;; + +-- --------------------------------------------------------------------------- +-- Dirty marking (trigger side) +-- --------------------------------------------------------------------------- + +-- Enqueue the leaf cell (z=11 grid == slippy zoom 15) covering a point. +-- No neighbour buffering is needed: with grid binning a task belongs to +-- exactly one cell, and the recompute is authoritative. +CREATE OR REPLACE FUNCTION mark_dirty_leaf_cell(p_loc geometry) RETURNS VOID AS $$ +BEGIN + IF p_loc IS NULL OR ST_IsEmpty(p_loc) THEN + RETURN;; + END IF;; + IF ST_X(p_loc) < -180 OR ST_X(p_loc) > 180 + OR ST_Y(p_loc) < -85.05112878 OR ST_Y(p_loc) > 85.05112878 THEN + RETURN;; + END IF;; + INSERT INTO tile_dirty_cells (cx, cy) + VALUES (lng_to_tile_x(ST_X(p_loc), 15), lat_to_tile_y(ST_Y(p_loc), 15)) + ON CONFLICT (cx, cy) DO NOTHING;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +-- A single task changed: mark the leaf cell of its old and new locations. +-- Fires for INSERT / UPDATE / DELETE -- the body decides what is relevant +-- (status, location, parent_id, archived). A bare AFTER ... trigger -- with no +-- column list -- is used so this evolution has no DDL dependency on the +-- `archived` column (added by a later evolution) -- it is resolved lazily. +CREATE OR REPLACE FUNCTION mark_dirty_on_task_change() RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'UPDATE' + AND OLD.status IS NOT DISTINCT FROM NEW.status + AND OLD.parent_id IS NOT DISTINCT FROM NEW.parent_id + AND OLD.archived IS NOT DISTINCT FROM NEW.archived + AND ST_Equals(COALESCE(OLD.location, ST_GeomFromText('POINT EMPTY', 4326)), + COALESCE(NEW.location, ST_GeomFromText('POINT EMPTY', 4326))) THEN + RETURN NEW;; + END IF;; + + IF TG_OP IN ('UPDATE', 'DELETE') THEN + PERFORM mark_dirty_leaf_cell(OLD.location);; + END IF;; + IF TG_OP IN ('UPDATE', 'INSERT') THEN + PERFORM mark_dirty_leaf_cell(NEW.location);; + END IF;; + + IF TG_OP = 'DELETE' THEN + RETURN OLD;; + END IF;; + RETURN NEW;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +DROP TRIGGER IF EXISTS mark_tiles_dirty_on_task_change_trigger ON tasks;; +DROP TRIGGER IF EXISTS mark_dirty_on_task_change_trigger ON tasks;; +CREATE TRIGGER mark_dirty_on_task_change_trigger + AFTER INSERT OR UPDATE OR DELETE ON tasks + FOR EACH ROW EXECUTE PROCEDURE mark_dirty_on_task_change();; + +-- A challenge attribute that changes task eligibility (deleted / enabled / +-- is_archived) or filter bucketing (is_global / difficulty) flipped: mark +-- every leaf cell holding one of its tasks. The recompute applies the real +-- eligibility filter, so over-marking here is harmless. +CREATE OR REPLACE FUNCTION mark_dirty_on_challenge_change() RETURNS TRIGGER AS $$ +BEGIN + IF OLD.deleted IS NOT DISTINCT FROM NEW.deleted + AND OLD.enabled IS NOT DISTINCT FROM NEW.enabled + AND OLD.is_archived IS NOT DISTINCT FROM NEW.is_archived + AND OLD.is_global IS NOT DISTINCT FROM NEW.is_global + AND OLD.difficulty IS NOT DISTINCT FROM NEW.difficulty THEN + RETURN NEW;; + END IF;; + + INSERT INTO tile_dirty_cells (cx, cy) + SELECT DISTINCT + lng_to_tile_x(ST_X(t.location), 15), + lat_to_tile_y(ST_Y(t.location), 15) + FROM tasks t + WHERE t.parent_id = NEW.id + AND t.location IS NOT NULL + AND NOT ST_IsEmpty(t.location) + AND ST_X(t.location) BETWEEN -180 AND 180 + AND ST_Y(t.location) BETWEEN -85.05112878 AND 85.05112878 + ON CONFLICT (cx, cy) DO NOTHING;; + + RETURN NEW;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +DROP TRIGGER IF EXISTS mark_tiles_dirty_on_challenge_change_trigger ON challenges;; +DROP TRIGGER IF EXISTS mark_dirty_on_challenge_change_trigger ON challenges;; +CREATE TRIGGER mark_dirty_on_challenge_change_trigger + AFTER UPDATE OF deleted, enabled, is_archived, is_global, difficulty ON challenges + FOR EACH ROW EXECUTE PROCEDURE mark_dirty_on_challenge_change();; + +-- A project attribute that changes task eligibility (deleted / enabled) +-- flipped: mark every leaf cell holding a task under any of the project's +-- challenges. Hard project deletes are already covered -- they cascade to +-- tasks, firing the per-task trigger. +CREATE OR REPLACE FUNCTION mark_dirty_on_project_change() RETURNS TRIGGER AS $$ +BEGIN + IF OLD.deleted IS NOT DISTINCT FROM NEW.deleted + AND OLD.enabled IS NOT DISTINCT FROM NEW.enabled THEN + RETURN NEW;; + END IF;; + + INSERT INTO tile_dirty_cells (cx, cy) + SELECT DISTINCT + lng_to_tile_x(ST_X(t.location), 15), + lat_to_tile_y(ST_Y(t.location), 15) + FROM tasks t + INNER JOIN challenges c ON c.id = t.parent_id + WHERE c.parent_id = NEW.id + AND t.location IS NOT NULL + AND NOT ST_IsEmpty(t.location) + AND ST_X(t.location) BETWEEN -180 AND 180 + AND ST_Y(t.location) BETWEEN -85.05112878 AND 85.05112878 + ON CONFLICT (cx, cy) DO NOTHING;; + + RETURN NEW;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +DROP TRIGGER IF EXISTS mark_dirty_on_project_change_trigger ON projects;; +CREATE TRIGGER mark_dirty_on_project_change_trigger + AFTER UPDATE OF deleted, enabled ON projects + FOR EACH ROW EXECUTE PROCEDURE mark_dirty_on_project_change();; + +-- --------------------------------------------------------------------------- +-- Recompute (drain side) +-- --------------------------------------------------------------------------- + +-- Recompute one leaf cell (display z=11) from the base tables. The eligibility +-- filter is the single source of truth for "available work", and is mirrored +-- verbatim by rebuild_all_tile_cells() and by the live MVT queries in +-- TileAggregateRepository -- keep all four in sync. +CREATE OR REPLACE FUNCTION rebuild_leaf_cell(p_cx INTEGER, p_cy INTEGER) RETURNS VOID AS $$ +DECLARE + env geometry := tile_envelope_4326(15, p_cx, p_cy);; +BEGIN + DELETE FROM tile_cells WHERE z = 11 AND cx = p_cx AND cy = p_cy;; + + INSERT INTO tile_cells (z, cx, cy, task_count, sum_lat, sum_lng, counts_by_filter) + SELECT + 11, p_cx, p_cy, + COUNT(*)::INTEGER, + SUM(ST_Y(t.location)), + SUM(ST_X(t.location)), + jsonb_build_object( + 'd1_gf', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 1 AND NOT COALESCE(c.is_global,false)), + 'd1_gt', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 1 AND COALESCE(c.is_global,false)), + 'd2_gf', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 2 AND NOT COALESCE(c.is_global,false)), + 'd2_gt', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 2 AND COALESCE(c.is_global,false)), + 'd3_gf', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 3 AND NOT COALESCE(c.is_global,false)), + 'd3_gt', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 3 AND COALESCE(c.is_global,false)), + 'd0_gf', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) NOT IN (1,2,3) AND NOT COALESCE(c.is_global,false)), + 'd0_gt', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) NOT IN (1,2,3) AND COALESCE(c.is_global,false)) + ) + FROM tasks t + INNER JOIN challenges c ON c.id = t.parent_id + INNER JOIN projects p ON p.id = c.parent_id + WHERE t.location && env + AND lng_to_tile_x(ST_X(t.location), 15) = p_cx + AND lat_to_tile_y(ST_Y(t.location), 15) = p_cy + AND NOT ST_IsEmpty(t.location) + AND ST_X(t.location) BETWEEN -180 AND 180 + AND ST_Y(t.location) BETWEEN -85.05112878 AND 85.05112878 + AND t.status IN (0, 3, 6) + AND t.archived = FALSE + AND c.deleted = FALSE AND c.enabled = TRUE AND c.is_archived = FALSE + AND p.deleted = FALSE AND p.enabled = TRUE + HAVING COUNT(*) > 0;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +-- Recompute one cell at display zoom p_z (0..10) by summing its four children +-- at p_z + 1. Exact: a parent cell is the precise union of its four children. +CREATE OR REPLACE FUNCTION rollup_cell(p_z INTEGER, p_cx INTEGER, p_cy INTEGER) RETURNS VOID AS $$ +BEGIN + DELETE FROM tile_cells WHERE z = p_z AND cx = p_cx AND cy = p_cy;; + + INSERT INTO tile_cells (z, cx, cy, task_count, sum_lat, sum_lng, counts_by_filter) + SELECT + p_z, p_cx, p_cy, + SUM(task_count)::INTEGER, + SUM(sum_lat), + SUM(sum_lng), + jsonb_build_object( + 'd1_gf', SUM(COALESCE((counts_by_filter->>'d1_gf')::int, 0)), + 'd1_gt', SUM(COALESCE((counts_by_filter->>'d1_gt')::int, 0)), + 'd2_gf', SUM(COALESCE((counts_by_filter->>'d2_gf')::int, 0)), + 'd2_gt', SUM(COALESCE((counts_by_filter->>'d2_gt')::int, 0)), + 'd3_gf', SUM(COALESCE((counts_by_filter->>'d3_gf')::int, 0)), + 'd3_gt', SUM(COALESCE((counts_by_filter->>'d3_gt')::int, 0)), + 'd0_gf', SUM(COALESCE((counts_by_filter->>'d0_gf')::int, 0)), + 'd0_gt', SUM(COALESCE((counts_by_filter->>'d0_gt')::int, 0)) + ) + FROM tile_cells + WHERE z = p_z + 1 + AND cx BETWEEN p_cx * 2 AND p_cx * 2 + 1 + AND cy BETWEEN p_cy * 2 AND p_cy * 2 + 1 + HAVING SUM(task_count) > 0;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +-- Full rebuild of the whole pyramid. Used for initial population and as a +-- crash-recovery safety net. Cheap relative to the old design: one scan of +-- `tasks` for the leaf level, then 11 additive roll-up passes. +CREATE OR REPLACE FUNCTION rebuild_all_tile_cells() RETURNS INTEGER AS $$ +DECLARE + i_z INTEGER;; + total INTEGER := 0;; + n INTEGER;; +BEGIN + TRUNCATE tile_cells, tile_dirty_cells;; + + -- Leaf level (display z=11) straight from the base tables. + INSERT INTO tile_cells (z, cx, cy, task_count, sum_lat, sum_lng, counts_by_filter) + SELECT + 11, + lng_to_tile_x(ST_X(t.location), 15), + lat_to_tile_y(ST_Y(t.location), 15), + COUNT(*)::INTEGER, + SUM(ST_Y(t.location)), + SUM(ST_X(t.location)), + jsonb_build_object( + 'd1_gf', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 1 AND NOT COALESCE(c.is_global,false)), + 'd1_gt', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 1 AND COALESCE(c.is_global,false)), + 'd2_gf', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 2 AND NOT COALESCE(c.is_global,false)), + 'd2_gt', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 2 AND COALESCE(c.is_global,false)), + 'd3_gf', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 3 AND NOT COALESCE(c.is_global,false)), + 'd3_gt', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) = 3 AND COALESCE(c.is_global,false)), + 'd0_gf', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) NOT IN (1,2,3) AND NOT COALESCE(c.is_global,false)), + 'd0_gt', COUNT(*) FILTER (WHERE COALESCE(c.difficulty,0) NOT IN (1,2,3) AND COALESCE(c.is_global,false)) + ) + FROM tasks t + INNER JOIN challenges c ON c.id = t.parent_id + INNER JOIN projects p ON p.id = c.parent_id + WHERE t.location IS NOT NULL + AND NOT ST_IsEmpty(t.location) + AND ST_X(t.location) BETWEEN -180 AND 180 + AND ST_Y(t.location) BETWEEN -85.05112878 AND 85.05112878 + AND t.status IN (0, 3, 6) + AND t.archived = FALSE + AND c.deleted = FALSE AND c.enabled = TRUE AND c.is_archived = FALSE + AND p.deleted = FALSE AND p.enabled = TRUE + GROUP BY 2, 3;; + GET DIAGNOSTICS n = ROW_COUNT;; + total := total + n;; + + -- Roll up display z = 10 .. 0 by summation. + FOR i_z IN REVERSE 10..0 LOOP + INSERT INTO tile_cells (z, cx, cy, task_count, sum_lat, sum_lng, counts_by_filter) + SELECT + i_z, cx >> 1, cy >> 1, + SUM(task_count)::INTEGER, + SUM(sum_lat), + SUM(sum_lng), + jsonb_build_object( + 'd1_gf', SUM(COALESCE((counts_by_filter->>'d1_gf')::int, 0)), + 'd1_gt', SUM(COALESCE((counts_by_filter->>'d1_gt')::int, 0)), + 'd2_gf', SUM(COALESCE((counts_by_filter->>'d2_gf')::int, 0)), + 'd2_gt', SUM(COALESCE((counts_by_filter->>'d2_gt')::int, 0)), + 'd3_gf', SUM(COALESCE((counts_by_filter->>'d3_gf')::int, 0)), + 'd3_gt', SUM(COALESCE((counts_by_filter->>'d3_gt')::int, 0)), + 'd0_gf', SUM(COALESCE((counts_by_filter->>'d0_gf')::int, 0)), + 'd0_gt', SUM(COALESCE((counts_by_filter->>'d0_gt')::int, 0)) + ) + FROM tile_cells + WHERE z = i_z + 1 + GROUP BY cx >> 1, cy >> 1;; + GET DIAGNOSTICS n = ROW_COUNT;; + total := total + n;; + END LOOP;; + + RETURN total;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +-- Drain the dirty-cell queue: pop up to p_limit leaf cells, recompute them +-- from the base tables, then roll the changes up z=10..0. +-- +-- A single global advisory lock (key 6552071) is held for the call so only +-- one drainer mutates the pyramid at a time -- this is what makes concurrent +-- roll-ups (scheduled drain vs. synchronous post-commit drain) safe. The lock +-- is transaction-scoped, so each call releases it on commit, and callers +-- on it block for at most one batch. +-- +-- p_newest_first drains the most recently marked cells first, used by +-- the synchronous post-commit drain so a user sees their own edit immediately. +CREATE OR REPLACE FUNCTION rebuild_dirty_cells( + p_limit INTEGER DEFAULT 512, + p_newest_first BOOLEAN DEFAULT FALSE +) RETURNS INTEGER AS $$ +DECLARE + processed INTEGER := 0;; + i_z INTEGER;; + rec RECORD;; +BEGIN + PERFORM pg_advisory_xact_lock(6552071);; + + CREATE TEMP TABLE IF NOT EXISTS _tile_work ( + z SMALLINT NOT NULL, + cx INTEGER NOT NULL, + cy INTEGER NOT NULL, + PRIMARY KEY (z, cx, cy) + ) ON COMMIT DROP;; + TRUNCATE _tile_work;; + + -- Pop leaf cells (display z=11) off the queue into the work set. The + -- DELETE ... RETURNING must sit in a WITH clause: a data-modifying + -- statement cannot be a plain subquery in FROM. + WITH popped AS ( + DELETE FROM tile_dirty_cells + WHERE (cx, cy) IN ( + SELECT cx, cy FROM tile_dirty_cells + ORDER BY (CASE WHEN p_newest_first THEN marked_at END) DESC NULLS LAST, + marked_at ASC + LIMIT p_limit + ) + RETURNING cx, cy + ) + INSERT INTO _tile_work (z, cx, cy) + SELECT 11, cx, cy FROM popped + ON CONFLICT DO NOTHING;; + + SELECT COUNT(*) INTO processed FROM _tile_work WHERE z = 11;; + IF processed = 0 THEN + RETURN 0;; + END IF;; + + -- Recompute the leaf cells. + FOR rec IN SELECT cx, cy FROM _tile_work WHERE z = 11 LOOP + PERFORM rebuild_leaf_cell(rec.cx, rec.cy);; + END LOOP;; + + -- Roll up: each level's dirty set is the distinct parents of the level below. + FOR i_z IN REVERSE 10..0 LOOP + INSERT INTO _tile_work (z, cx, cy) + SELECT i_z, cx >> 1, cy >> 1 FROM _tile_work WHERE z = i_z + 1 + ON CONFLICT DO NOTHING;; + + FOR rec IN SELECT cx, cy FROM _tile_work WHERE z = i_z LOOP + PERFORM rollup_cell(i_z, rec.cx, rec.cy);; + END LOOP;; + END LOOP;; + + RETURN processed;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +# --- !Downs + +DROP TRIGGER IF EXISTS mark_dirty_on_project_change_trigger ON projects;; +DROP TRIGGER IF EXISTS mark_dirty_on_challenge_change_trigger ON challenges;; +DROP TRIGGER IF EXISTS mark_dirty_on_task_change_trigger ON tasks;; +DROP FUNCTION IF EXISTS mark_dirty_on_project_change();; +DROP FUNCTION IF EXISTS mark_dirty_on_challenge_change();; +DROP FUNCTION IF EXISTS mark_dirty_on_task_change();; +DROP FUNCTION IF EXISTS rebuild_dirty_cells(INTEGER, BOOLEAN);; +DROP FUNCTION IF EXISTS rebuild_all_tile_cells();; +DROP FUNCTION IF EXISTS rollup_cell(INTEGER, INTEGER, INTEGER);; +DROP FUNCTION IF EXISTS rebuild_leaf_cell(INTEGER, INTEGER);; +DROP FUNCTION IF EXISTS mark_dirty_leaf_cell(geometry);; +DROP FUNCTION IF EXISTS tile_envelope_4326(INTEGER, INTEGER, INTEGER);; +DROP FUNCTION IF EXISTS lat_to_tile_y(DOUBLE PRECISION, INTEGER);; +DROP FUNCTION IF EXISTS lng_to_tile_x(DOUBLE PRECISION, INTEGER);; +DROP INDEX IF EXISTS idx_tile_dirty_cells_marked_at;; +DROP TABLE IF EXISTS tile_dirty_cells;; +DROP TABLE IF EXISTS tile_cells;; diff --git a/conf/evolutions/default/108.sql b/conf/evolutions/default/108.sql new file mode 100644 index 000000000..7b592835a --- /dev/null +++ b/conf/evolutions/default/108.sql @@ -0,0 +1,8 @@ +# --- !Ups + +CREATE EXTENSION IF NOT EXISTS pg_trgm;; +CREATE INDEX IF NOT EXISTS idx_tasks_name_trgm ON tasks USING gin (name gin_trgm_ops);; + +# --- !Downs + +DROP INDEX IF EXISTS idx_tasks_name_trgm;; diff --git a/conf/evolutions/default/109.sql b/conf/evolutions/default/109.sql new file mode 100644 index 000000000..f6cbc7515 --- /dev/null +++ b/conf/evolutions/default/109.sql @@ -0,0 +1,8 @@ +# --- MapRoulette Scheme + +# --- !Ups +-- Add plugins column to users table to store user's installed plugins configuration +ALTER TABLE users ADD COLUMN IF NOT EXISTS plugins TEXT;; + +# --- !Downs +ALTER TABLE users DROP COLUMN plugins;; diff --git a/conf/evolutions/default/110.sql b/conf/evolutions/default/110.sql new file mode 100644 index 000000000..85480515f --- /dev/null +++ b/conf/evolutions/default/110.sql @@ -0,0 +1,23 @@ +# --- MapRoulette Scheme + +# --- !Ups +-- Table for challenge likes +CREATE TABLE IF NOT EXISTS challenge_likes +( + id SERIAL NOT NULL PRIMARY KEY, + created timestamp without time zone DEFAULT NOW(), + user_id integer NOT NULL, + challenge_id integer NOT NULL, + CONSTRAINT challenge_likes_user_id FOREIGN KEY (user_id) + REFERENCES users(id) MATCH SIMPLE + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT challenge_likes_challenge_id FOREIGN KEY (challenge_id) + REFERENCES challenges(id) MATCH SIMPLE + ON UPDATE CASCADE ON DELETE CASCADE +);; +SELECT create_index_if_not_exists('challenge_likes', 'user_id', '(user_id)');; +SELECT create_index_if_not_exists('challenge_likes', 'challenge_id', '(challenge_id)');; +SELECT create_index_if_not_exists('challenge_likes', 'user_id_challenge_id', '(user_id, challenge_id)', true);; + +# --- !Downs +DROP TABLE IF EXISTS challenge_likes;; diff --git a/conf/evolutions/default/111.sql b/conf/evolutions/default/111.sql new file mode 100644 index 000000000..915d1ee09 --- /dev/null +++ b/conf/evolutions/default/111.sql @@ -0,0 +1,283 @@ +# --- MapRoulette Scheme + +# --- !Ups +-- ============================================================================= +-- CompletionMetrics on challenges and projects +-- ============================================================================= +-- +-- Persistent task-status completion metrics stored as a single JSONB blob on +-- challenges and projects so the counts are available directly on the object +-- without a separate stats call. + +ALTER TABLE challenges ADD COLUMN IF NOT EXISTS completion_metrics JSONB NOT NULL DEFAULT '{}'::jsonb;; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS completion_metrics JSONB NOT NULL DEFAULT '{}'::jsonb;; + +-- Build a CompletionMetrics JSON object from raw task counts. +CREATE OR REPLACE FUNCTION build_completion_metrics( + total INTEGER, available INTEGER, fixed INTEGER, false_positive INTEGER, + skipped INTEGER, deleted INTEGER, already_fixed INTEGER, too_hard INTEGER, + answered INTEGER, validated INTEGER, disabled INTEGER +) RETURNS JSONB AS $$ +BEGIN + RETURN jsonb_build_object( + 'total', COALESCE(total, 0), + 'available', COALESCE(available, 0), + 'fixed', COALESCE(fixed, 0), + 'falsePositive', COALESCE(false_positive, 0), + 'skipped', COALESCE(skipped, 0), + 'deleted', COALESCE(deleted, 0), + 'alreadyFixed', COALESCE(already_fixed, 0), + 'tooHard', COALESCE(too_hard, 0), + 'answered', COALESCE(answered, 0), + 'validated', COALESCE(validated, 0), + 'disabled', COALESCE(disabled, 0) + );; +END;; +$$ LANGUAGE plpgsql IMMUTABLE;; + +CREATE OR REPLACE FUNCTION empty_completion_metrics() RETURNS JSONB AS $$ + SELECT build_completion_metrics(0,0,0,0,0,0,0,0,0,0,0);; +$$ LANGUAGE SQL IMMUTABLE;; + +CREATE OR REPLACE FUNCTION metrics_get(metrics JSONB, key TEXT) RETURNS INTEGER AS $$ + SELECT COALESCE((metrics ->> key)::int, 0);; +$$ LANGUAGE SQL IMMUTABLE;; + +CREATE OR REPLACE FUNCTION metrics_apply_delta(metrics JSONB, key TEXT, delta INTEGER) +RETURNS JSONB AS $$ + SELECT jsonb_set( + COALESCE(metrics, empty_completion_metrics()), + ARRAY[key], + to_jsonb(GREATEST(metrics_get(COALESCE(metrics, empty_completion_metrics()), key) + delta, 0)) + );; +$$ LANGUAGE SQL IMMUTABLE;; + +CREATE OR REPLACE FUNCTION task_status_metric_key(status INTEGER) RETURNS TEXT AS $$ + SELECT CASE status + WHEN 0 THEN 'available' + WHEN 1 THEN 'fixed' + WHEN 2 THEN 'falsePositive' + WHEN 3 THEN 'skipped' + WHEN 4 THEN 'deleted' + WHEN 5 THEN 'alreadyFixed' + WHEN 6 THEN 'tooHard' + WHEN 7 THEN 'answered' + WHEN 8 THEN 'validated' + WHEN 9 THEN 'disabled' + ELSE NULL + END;; +$$ LANGUAGE SQL IMMUTABLE;; + +CREATE OR REPLACE FUNCTION metrics_add(a JSONB, b JSONB) RETURNS JSONB AS $$ + SELECT build_completion_metrics( + metrics_get(a, 'total') + metrics_get(b, 'total'), + metrics_get(a, 'available') + metrics_get(b, 'available'), + metrics_get(a, 'fixed') + metrics_get(b, 'fixed'), + metrics_get(a, 'falsePositive') + metrics_get(b, 'falsePositive'), + metrics_get(a, 'skipped') + metrics_get(b, 'skipped'), + metrics_get(a, 'deleted') + metrics_get(b, 'deleted'), + metrics_get(a, 'alreadyFixed') + metrics_get(b, 'alreadyFixed'), + metrics_get(a, 'tooHard') + metrics_get(b, 'tooHard'), + metrics_get(a, 'answered') + metrics_get(b, 'answered'), + metrics_get(a, 'validated') + metrics_get(b, 'validated'), + metrics_get(a, 'disabled') + metrics_get(b, 'disabled') + );; +$$ LANGUAGE SQL IMMUTABLE;; + +CREATE OR REPLACE FUNCTION metrics_sub(a JSONB, b JSONB) RETURNS JSONB AS $$ + SELECT build_completion_metrics( + GREATEST(metrics_get(a, 'total') - metrics_get(b, 'total'), 0), + GREATEST(metrics_get(a, 'available') - metrics_get(b, 'available'), 0), + GREATEST(metrics_get(a, 'fixed') - metrics_get(b, 'fixed'), 0), + GREATEST(metrics_get(a, 'falsePositive') - metrics_get(b, 'falsePositive'), 0), + GREATEST(metrics_get(a, 'skipped') - metrics_get(b, 'skipped'), 0), + GREATEST(metrics_get(a, 'deleted') - metrics_get(b, 'deleted'), 0), + GREATEST(metrics_get(a, 'alreadyFixed') - metrics_get(b, 'alreadyFixed'), 0), + GREATEST(metrics_get(a, 'tooHard') - metrics_get(b, 'tooHard'), 0), + GREATEST(metrics_get(a, 'answered') - metrics_get(b, 'answered'), 0), + GREATEST(metrics_get(a, 'validated') - metrics_get(b, 'validated'), 0), + GREATEST(metrics_get(a, 'disabled') - metrics_get(b, 'disabled'), 0) + );; +$$ LANGUAGE SQL IMMUTABLE;; + +-- Backfill challenge completion_metrics and the legacy +-- completion_percentage column (tasks_remaining from evolution 84 is +-- left alone). +UPDATE challenges c +SET + completion_metrics = build_completion_metrics( + agg.total, agg.available, agg.fixed, agg.false_positive, agg.skipped, + agg.deleted, agg.already_fixed, agg.too_hard, agg.answered, agg.validated, + agg.disabled + ), + completion_percentage = CASE + WHEN COALESCE(agg.completable_total, 0) = 0 THEN 0 + ELSE ROUND( + (COALESCE(agg.fixed, 0) + COALESCE(agg.false_positive, 0) + COALESCE(agg.already_fixed, 0))::numeric + * 100 / agg.completable_total + )::int + END +FROM ( + SELECT parent_id, + COUNT(*)::int AS total, + SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END)::int AS available, + SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END)::int AS fixed, + SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END)::int AS false_positive, + SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END)::int AS skipped, + SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END)::int AS deleted, + SUM(CASE WHEN status = 5 THEN 1 ELSE 0 END)::int AS already_fixed, + SUM(CASE WHEN status = 6 THEN 1 ELSE 0 END)::int AS too_hard, + SUM(CASE WHEN status = 7 THEN 1 ELSE 0 END)::int AS answered, + SUM(CASE WHEN status = 8 THEN 1 ELSE 0 END)::int AS validated, + SUM(CASE WHEN status = 9 THEN 1 ELSE 0 END)::int AS disabled, + SUM(CASE WHEN status NOT IN (4, 9) THEN 1 ELSE 0 END)::int AS completable_total + FROM tasks + GROUP BY parent_id +) agg +WHERE c.id = agg.parent_id;; + +UPDATE challenges +SET completion_metrics = empty_completion_metrics() +WHERE completion_metrics = '{}'::jsonb OR completion_metrics IS NULL;; + +UPDATE projects p +SET completion_metrics = agg.metrics +FROM ( + SELECT parent_id, + build_completion_metrics( + SUM(metrics_get(completion_metrics, 'total'))::int, + SUM(metrics_get(completion_metrics, 'available'))::int, + SUM(metrics_get(completion_metrics, 'fixed'))::int, + SUM(metrics_get(completion_metrics, 'falsePositive'))::int, + SUM(metrics_get(completion_metrics, 'skipped'))::int, + SUM(metrics_get(completion_metrics, 'deleted'))::int, + SUM(metrics_get(completion_metrics, 'alreadyFixed'))::int, + SUM(metrics_get(completion_metrics, 'tooHard'))::int, + SUM(metrics_get(completion_metrics, 'answered'))::int, + SUM(metrics_get(completion_metrics, 'validated'))::int, + SUM(metrics_get(completion_metrics, 'disabled'))::int + ) AS metrics + FROM challenges + WHERE deleted = false + GROUP BY parent_id +) agg +WHERE p.id = agg.parent_id;; + +UPDATE projects +SET completion_metrics = empty_completion_metrics() +WHERE completion_metrics = '{}'::jsonb OR completion_metrics IS NULL;; + +-- Trigger: maintain challenge completion_metrics on task insert/update/delete. +CREATE OR REPLACE FUNCTION update_challenge_completion_metrics() RETURNS TRIGGER AS $$ +DECLARE + old_key TEXT;; + new_key TEXT;; +BEGIN + IF TG_OP = 'INSERT' THEN + new_key := task_status_metric_key(NEW.status);; + UPDATE challenges SET completion_metrics = + metrics_apply_delta( + metrics_apply_delta(completion_metrics, 'total', 1), + new_key, 1 + ) + WHERE id = NEW.parent_id AND new_key IS NOT NULL;; + RETURN NEW;; + ELSIF TG_OP = 'DELETE' THEN + old_key := task_status_metric_key(OLD.status);; + UPDATE challenges SET completion_metrics = + metrics_apply_delta( + metrics_apply_delta(completion_metrics, 'total', -1), + old_key, -1 + ) + WHERE id = OLD.parent_id AND old_key IS NOT NULL;; + RETURN OLD;; + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.parent_id IS DISTINCT FROM NEW.parent_id THEN + old_key := task_status_metric_key(OLD.status);; + new_key := task_status_metric_key(NEW.status);; + UPDATE challenges SET completion_metrics = + metrics_apply_delta( + metrics_apply_delta(completion_metrics, 'total', -1), + old_key, -1 + ) + WHERE id = OLD.parent_id AND old_key IS NOT NULL;; + UPDATE challenges SET completion_metrics = + metrics_apply_delta( + metrics_apply_delta(completion_metrics, 'total', 1), + new_key, 1 + ) + WHERE id = NEW.parent_id AND new_key IS NOT NULL;; + ELSIF OLD.status IS DISTINCT FROM NEW.status THEN + old_key := task_status_metric_key(OLD.status);; + new_key := task_status_metric_key(NEW.status);; + UPDATE challenges SET completion_metrics = + metrics_apply_delta( + metrics_apply_delta(completion_metrics, COALESCE(old_key, 'total'), CASE WHEN old_key IS NULL THEN 0 ELSE -1 END), + COALESCE(new_key, 'total'), CASE WHEN new_key IS NULL THEN 0 ELSE 1 END + ) + WHERE id = NEW.parent_id;; + END IF;; + RETURN NEW;; + END IF;; + RETURN NULL;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +DROP TRIGGER IF EXISTS update_challenge_completion_metrics_trigger ON tasks;; +CREATE TRIGGER update_challenge_completion_metrics_trigger + AFTER INSERT OR UPDATE OR DELETE ON tasks + FOR EACH ROW EXECUTE PROCEDURE update_challenge_completion_metrics();; + +-- Trigger: roll challenge-level deltas up to the parent project. +CREATE OR REPLACE FUNCTION update_project_completion_metrics() RETURNS TRIGGER AS $$ +DECLARE + old_contributes BOOLEAN := (TG_OP IN ('UPDATE', 'DELETE')) AND NOT OLD.deleted;; + new_contributes BOOLEAN := (TG_OP IN ('INSERT', 'UPDATE')) AND NOT NEW.deleted;; +BEGIN + IF TG_OP = 'DELETE' THEN + new_contributes := false;; + END IF;; + IF TG_OP = 'INSERT' THEN + old_contributes := false;; + END IF;; + + IF old_contributes THEN + UPDATE projects + SET completion_metrics = metrics_sub(completion_metrics, OLD.completion_metrics) + WHERE id = OLD.parent_id;; + END IF;; + + IF new_contributes THEN + UPDATE projects + SET completion_metrics = metrics_add(completion_metrics, NEW.completion_metrics) + WHERE id = NEW.parent_id;; + END IF;; + + IF TG_OP = 'DELETE' THEN + RETURN OLD;; + END IF;; + RETURN NEW;; +END;; +$$ LANGUAGE plpgsql VOLATILE;; + +DROP TRIGGER IF EXISTS update_project_completion_metrics_trigger ON challenges;; +CREATE TRIGGER update_project_completion_metrics_trigger + AFTER INSERT OR UPDATE OR DELETE ON challenges + FOR EACH ROW EXECUTE PROCEDURE update_project_completion_metrics();; + + +# --- !Downs +DROP TRIGGER IF EXISTS update_project_completion_metrics_trigger ON challenges;; +DROP FUNCTION IF EXISTS update_project_completion_metrics();; +DROP TRIGGER IF EXISTS update_challenge_completion_metrics_trigger ON tasks;; +DROP FUNCTION IF EXISTS update_challenge_completion_metrics();; +DROP FUNCTION IF EXISTS metrics_sub(JSONB, JSONB);; +DROP FUNCTION IF EXISTS metrics_add(JSONB, JSONB);; +DROP FUNCTION IF EXISTS task_status_metric_key(INTEGER);; +DROP FUNCTION IF EXISTS metrics_apply_delta(JSONB, TEXT, INTEGER);; +DROP FUNCTION IF EXISTS metrics_get(JSONB, TEXT);; +DROP FUNCTION IF EXISTS empty_completion_metrics();; +DROP FUNCTION IF EXISTS build_completion_metrics(INTEGER, INTEGER, INTEGER, INTEGER, INTEGER, INTEGER, INTEGER, INTEGER, INTEGER, INTEGER, INTEGER);; + +ALTER TABLE IF EXISTS projects DROP COLUMN IF EXISTS completion_metrics;; +ALTER TABLE IF EXISTS challenges DROP COLUMN IF EXISTS completion_metrics;; diff --git a/conf/evolutions/default/112.sql b/conf/evolutions/default/112.sql new file mode 100644 index 000000000..4acff2ffe --- /dev/null +++ b/conf/evolutions/default/112.sql @@ -0,0 +1,26 @@ +# --- MapRoulette Scheme + +# --- !Ups +-- ============================================================================= +-- Part 1: Per-task skip_count +-- ============================================================================= + +ALTER TABLE IF EXISTS tasks + ADD COLUMN IF NOT EXISTS skip_count INTEGER NOT NULL DEFAULT 0;; + +CREATE INDEX IF NOT EXISTS idx_tasks_skip_count ON tasks(skip_count);; + +-- ============================================================================= +-- Part 2: Task archive flag (for bulk archive without deleting) +-- ============================================================================= + +ALTER TABLE IF EXISTS tasks + ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;; + +CREATE INDEX IF NOT EXISTS idx_tasks_archived ON tasks(archived);; + +# --- !Downs +DROP INDEX IF EXISTS idx_tasks_skip_count;; +ALTER TABLE IF EXISTS tasks DROP COLUMN IF EXISTS skip_count;; +DROP INDEX IF EXISTS idx_tasks_archived;; +ALTER TABLE IF EXISTS tasks DROP COLUMN IF EXISTS archived;; diff --git a/conf/evolutions/default/113.sql b/conf/evolutions/default/113.sql new file mode 100644 index 000000000..d4f9318c7 --- /dev/null +++ b/conf/evolutions/default/113.sql @@ -0,0 +1,13 @@ +# --- !Ups + +-- Support ILIKE '%term%' comment search without a full table scan. +CREATE EXTENSION IF NOT EXISTS pg_trgm;; +CREATE INDEX IF NOT EXISTS idx_task_comments_comment_trgm + ON task_comments USING gin (comment gin_trgm_ops);; +CREATE INDEX IF NOT EXISTS idx_challenge_comments_comment_trgm + ON challenge_comments USING gin (comment gin_trgm_ops);; + +# --- !Downs + +DROP INDEX IF EXISTS idx_task_comments_comment_trgm;; +DROP INDEX IF EXISTS idx_challenge_comments_comment_trgm;; diff --git a/conf/evolutions/default/114.sql b/conf/evolutions/default/114.sql new file mode 100644 index 000000000..fda3ed9ce --- /dev/null +++ b/conf/evolutions/default/114.sql @@ -0,0 +1,25 @@ +# --- MapRoulette Scheme + +# --- !Ups +-- Normalize legacy no-value sentinel strings in challenge JSON columns to SQL +-- NULL (or the column default for NOT NULL columns). This lets us simplify +-- the read path in the code since we don't need to check for these values. + +UPDATE challenges +SET task_widget_layout = '{}'::jsonb +WHERE jsonb_typeof(task_widget_layout) = 'string' + AND task_widget_layout::text = '""';; + +UPDATE challenges SET task_styles = NULL +WHERE task_styles IN ('', '[]', 'null');; + +UPDATE challenges SET high_priority_rule = NULL WHERE high_priority_rule IN ('', '{}');; +UPDATE challenges SET medium_priority_rule = NULL WHERE medium_priority_rule IN ('', '{}');; +UPDATE challenges SET low_priority_rule = NULL WHERE low_priority_rule IN ('', '{}');; + +UPDATE challenges SET high_priority_bounds = NULL WHERE high_priority_bounds IN ('', '[]');; +UPDATE challenges SET medium_priority_bounds = NULL WHERE medium_priority_bounds IN ('', '[]');; +UPDATE challenges SET low_priority_bounds = NULL WHERE low_priority_bounds IN ('', '[]');; + +# --- !Downs +SELECT 1;; diff --git a/conf/evolutions/default/115.sql b/conf/evolutions/default/115.sql new file mode 100644 index 000000000..591cc0d59 --- /dev/null +++ b/conf/evolutions/default/115.sql @@ -0,0 +1,70 @@ +# --- MapRoulette Scheme + +# --- !Ups + +-- Drop the legacy task_geometries table and its associated update_geometry +-- function. This table was originally used to store task geojsons, but +-- commit b95bfe2d moved this data into the main tasks table. migration +-- was done lazily at read time in the Scala code. today, every task in +-- the prod database has a non-null geojson column, so the task_geometries +-- table can safely be dropped. +DROP FUNCTION IF EXISTS update_geometry(bigint);; +DROP TABLE IF EXISTS task_geometries CASCADE;; + +-- Add NOT NULL to tasks.geojson. This only locks briefly. +-- See https://dba.stackexchange.com/questions/267947/ +ALTER TABLE tasks + ADD CONSTRAINT tasks_geojson_not_null CHECK (geojson IS NOT NULL) NOT VALID;; +ALTER TABLE tasks VALIDATE CONSTRAINT tasks_geojson_not_null;; +ALTER TABLE tasks ALTER COLUMN geojson SET NOT NULL;; +ALTER TABLE tasks DROP CONSTRAINT tasks_geojson_not_null;; + +# --- !Downs +ALTER TABLE tasks ALTER COLUMN geojson DROP NOT NULL;; + +-- Restore task_geometries. Copied verbatim from evolution 1.sql (lines 293-313). +CREATE TABLE IF NOT EXISTS task_geometries +( + id SERIAL NOT NULL PRIMARY KEY, + task_id integer NOT NULL, + properties HSTORE, + CONSTRAINT task_geometries_task_id_fkey FOREIGN KEY (task_id) + REFERENCES tasks (id) MATCH SIMPLE + ON UPDATE CASCADE ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED +);; + +DO $$ +BEGIN + PERFORM column_name FROM information_schema.columns WHERE table_name = 'task_geometries' AND column_name = 'geom';; + IF NOT FOUND THEN + PERFORM AddGeometryColumn('task_geometries', 'geom', 4326, 'GEOMETRY', 2);; + END IF;; +END$$;; + +CREATE INDEX IF NOT EXISTS idx_task_geometries_geom ON task_geometries USING GIST (geom);; +SELECT create_index_if_not_exists('task_geometries', 'task_id', '(task_id)');; + +-- Restore update_geometry. Copied verbatim from evolution 61.sql (lines 132-153). +DROP FUNCTION IF EXISTS update_geometry(bigint);; +CREATE OR REPLACE FUNCTION update_geometry(task_identifier bigint) + RETURNS TABLE(geo TEXT, loc TEXT, fix_geo TEXT) AS $$ +BEGIN + UPDATE tasks t SET geojson = geoms.geometries FROM (SELECT ROW_TO_JSON(fc)::JSONB AS geometries + FROM ( SELECT 'FeatureCollection' AS type, ARRAY_TO_JSON(array_agg(f)) AS features + FROM ( SELECT 'Feature' AS type, + ST_AsGeoJSON(lg.geom)::JSONB AS geometry, + HSTORE_TO_JSON(lg.properties) AS properties + FROM task_geometries AS lg + WHERE task_id = task_identifier + ) AS f + ) AS fc) AS geoms WHERE id = task_identifier;; + -- Update the geometry and location columns + UPDATE tasks t SET geom = geoms.geometry, location = ST_CENTROID(geoms.geometry) + FROM (SELECT ST_COLLECT(ST_MAKEVALID(geom)) AS geometry FROM ( + SELECT geom FROM task_geometries WHERE task_id = task_identifier + ) AS innerQuery) AS geoms WHERE id = task_identifier;; + RETURN QUERY SELECT geojson::TEXT, ST_AsGeoJSON(location) AS geo_location, cooperative_work_json::TEXT FROM tasks + WHERE id = task_identifier;; +END +$$ LANGUAGE plpgsql VOLATILE;; diff --git a/conf/evolutions/default/116.sql b/conf/evolutions/default/116.sql new file mode 100644 index 000000000..74393b79a --- /dev/null +++ b/conf/evolutions/default/116.sql @@ -0,0 +1,25 @@ +# --- MapRoulette Scheme + +# --- !Ups + +-- Add NOT NULL to tasks.geom and tasks.location. Both columns are derived +-- from tasks.geojson at insert time, and a NULL value here would mean the +-- row's GeoJSON could not be parsed into a PostGIS geometry. +-- +-- Adding these constraints only locks the tasks table briefly. +-- See https://dba.stackexchange.com/questions/267947/ +ALTER TABLE tasks + ADD CONSTRAINT tasks_geom_not_null CHECK (geom IS NOT NULL) NOT VALID;; +ALTER TABLE tasks VALIDATE CONSTRAINT tasks_geom_not_null;; +ALTER TABLE tasks ALTER COLUMN geom SET NOT NULL;; +ALTER TABLE tasks DROP CONSTRAINT tasks_geom_not_null;; + +ALTER TABLE tasks + ADD CONSTRAINT tasks_location_not_null CHECK (location IS NOT NULL) NOT VALID;; +ALTER TABLE tasks VALIDATE CONSTRAINT tasks_location_not_null;; +ALTER TABLE tasks ALTER COLUMN location SET NOT NULL;; +ALTER TABLE tasks DROP CONSTRAINT tasks_location_not_null;; + +# --- !Downs +ALTER TABLE tasks ALTER COLUMN geom DROP NOT NULL;; +ALTER TABLE tasks ALTER COLUMN location DROP NOT NULL;; diff --git a/conf/swagger-custom-mappings.yml b/conf/swagger-custom-mappings.yml index e12ae9b50..b55151f85 100644 --- a/conf/swagger-custom-mappings.yml +++ b/conf/swagger-custom-mappings.yml @@ -1,13 +1,20 @@ --- - - type: play\.api\.libs\.json\.jsvalue - specAsParameter: [] - specAsProperty: - type: string - type: play\.api\.libs\.json\.jsobject specAsParameter: [] specAsProperty: type: object - - type: play\.api\.libs\.oauth\.requesttoken + additionalProperties: true + - type: play\.api\.libs\.json\.jsarray specAsParameter: [] specAsProperty: - type: object + type: array + items: {} + # Type generic JS values as 'any'. Client libraries will treat this + # broadly (e.g. openapi-typescript will convert this to 'unknown'), + # so consumers are forced to narrow the type manually when they use + # it. Since the ergonomics of this aren't great, you should use a + # more specific type (like JsObject or JsArray) over JsValue when + # possible. + - type: play\.api\.libs\.json\.jsvalue + specAsParameter: [] + specAsProperty: {} diff --git a/conf/test.conf b/conf/test.conf new file mode 100644 index 000000000..2c7379bd2 --- /dev/null +++ b/conf/test.conf @@ -0,0 +1,38 @@ +include "application.conf" + +# Test configuration for Playwright E2E tests +# This uses environment variables set by the test setup + +play.http.secret.key = "TEST_SECRET_KEY_32_CHARS_LONG_12345678901234567890" +play.http.secret.key = ${?APPLICATION_SECRET} + +db.default { + url=${?MR_DATABASE_URL} + username=${?MR_DATABASE_USERNAME} + password=${?MR_DATABASE_PASSWORD} + logSql=false +} + +maproulette { + debug=true + bootstrap=true + + secret.key = "TEST_SECRET_KEY_32_CHARS_LONG_12345678901234567890" + secret.key = ${?MAPROULETTE_SECRET_KEY} + + scheduler { + startTimeJitterForMinuteTasks = "15 seconds" + startTimeJitterForHourTasks = "30 seconds" + + # Keep the background tile-cell drain off during tests so the dirty-cell + # queue is observable deterministically. + rebuildDirtyTileCells.interval = "" + } +} + +osm { + server=${?MR_OSM_SERVER} + consumerKey=${?MR_OAUTH_CONSUMER_KEY} + consumerSecret=${?MR_OAUTH_CONSUMER_SECRET} +} + diff --git a/conf/v2_route/bundle.api b/conf/v2_route/bundle.api index 3a6d25ff0..120636d02 100644 --- a/conf/v2_route/bundle.api +++ b/conf/v2_route/bundle.api @@ -1,5 +1,6 @@ ### # tags: [ Bundle ] +# operationId: bundle_create_a_task_bundle # summary: Create a task bundle # description: Create a new task bundle with the task ids in the supplied JSON body. # responses: @@ -22,6 +23,7 @@ POST /taskBundle @org.maproulette.framework.controller.TaskBundleController.createTaskBundle ### # tags: [ Bundle ] +# operationId: bundle_gets_a_task_bundle # summary: Gets a Task Bundle # description: Gets a task bundle based on the supplied id # responses: @@ -47,6 +49,7 @@ POST /taskBundle @org.maproulette.framework.c POST /taskBundle/:id @org.maproulette.framework.controller.TaskBundleController.getTaskBundle(id:Long, lockTasks:Boolean ?= false) ### # tags: [ Bundle ] +# operationId: bundle_updates_a_task_bundle # summary: Updates a Task Bundle # description: Sets the bundle to the tasks provided, and unlock all tasks removed from current bundle # responses: @@ -66,6 +69,7 @@ POST /taskBundle/:id @org.maproulette.framework. POST /taskBundle/:id/update @org.maproulette.framework.controller.TaskBundleController.updateTaskBundle(id: Long, taskIds: List[Long]) ### # tags: [ Bundle ] +# operationId: bundle_deletes_a_task_bundle # summary: Deletes a Task Bundle # description: Deletes a task bundle based on the supplied id # responses: @@ -82,6 +86,7 @@ POST /taskBundle/:id/update @org.maproulette.framew DELETE /taskBundle/:id @org.maproulette.framework.controller.TaskBundleController.deleteTaskBundle(id:Long) ### # tags: [ Bundle ] +# operationId: bundle_unbundles_tasks_from_task_bundle # summary: Unbundles tasks from Task Bundle # description: Removes a list of tasks from a bundle of tasks # responses: diff --git a/conf/v2_route/challenge.api b/conf/v2_route/challenge.api index eab4aa3c2..d9e5207f1 100644 --- a/conf/v2_route/challenge.api +++ b/conf/v2_route/challenge.api @@ -1,5 +1,6 @@ ### # tags: [ Challenge ] +# operationId: challenge_create # summary: Create a Challenge # description: Will create a new challenge from the supplied JSON in the body. When creating the Challenge, leave the ID field # out of the body json, if updating (generally use the PUT method) include the ID field. @@ -31,6 +32,26 @@ POST /challenge @org.maproulette.controllers.api.ChallengeController.create ### # tags: [ Challenge ] +# operationId: challenge_task_markers +# summary: Retrieves Task Marker Data +# description: Retrieves task marker data with separate arrays for single markers and overlapping markers. +# Overlapping markers represent multiple tasks at the same location (within 0.1 meters). +# responses: +# '200': +# description: Response containing separate arrays for single task markers and overlapping task markers. +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/org.maproulette.framework.model.ChallengeTaskMarkersResponse' +# parameters: +# - name: id +# in: path +# description: Id of the challenge +### +GET /challenge/:id/taskMarkers @org.maproulette.controllers.api.ChallengeController.getChallengeTaskMarkers(id:Long) +### +# tags: [ Challenge ] +# operationId: challenge_update # summary: Updates a Challenge # description: Will update an already existing challenge from the supplied JSON in the body. # When updating the Challenge object you can within the same json body include Task @@ -63,6 +84,81 @@ POST /challenge @org.maproulette.controllers PUT /challenge/:id @org.maproulette.controllers.api.ChallengeController.update(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_save_or_update +# summary: Create or update a Challenge +# description: Convenience endpoint that creates a new Challenge when the JSON body +# has no `id`, or updates the existing Challenge when an `id` is +# supplied. Identical payload shape in both cases. +# responses: +# '200': +# description: The persisted Challenge +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/org.maproulette.framework.model.Challenge' +# '400': +# description: Invalid json payload for Challenge +# '401': +# description: The user is not authorized to make this request +# requestBody: +# description: Full Challenge body, optionally including `id` for updates. +# required: true +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/org.maproulette.framework.model.Challenge' +### +POST /challenge/saveOrUpdate @org.maproulette.controllers.api.ChallengeController.saveOrUpdate +### +# tags: [ Challenge ] +# operationId: challenge_update_priorities +# summary: Update priority rules on a Challenge (priority-only) +# description: Narrow endpoint that only updates the defaultPriority plus the +# three priority rule groups and bounds on the challenge. Avoids +# re-validating unrelated fields. Body keys (all optional) — +# defaultPriority, highPriorityRule, highPriorityBounds, +# mediumPriorityRule, mediumPriorityBounds, lowPriorityRule, +# lowPriorityBounds. +# responses: +# '200': +# description: The updated Challenge +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/org.maproulette.framework.model.Challenge' +# '404': +# description: Challenge not found +# parameters: +# - name: id +# in: path +# description: The ID of the Challenge being updated +### +PUT /challenge/:id/priorities @org.maproulette.controllers.api.ChallengeController.updatePriorities(id:Long) +### +# tags: [ Challenge ] +# operationId: challenge_preview_priorities +# summary: Dry-run priority recompute for the supplied draft config +# description: | +# Accepts the same body shape as `PUT /challenge/:id/priorities` but does +# not persist anything. Returns the priority each task in the challenge +# would receive under the supplied draft config, plus per-tier counts. +# Used by the prioritization editor to power a server-accurate preview. +# responses: +# '200': +# description: The computed priorities by task id and aggregate counts +# '401': +# description: The user is not authorized to make this request +# '404': +# description: Challenge not found +# parameters: +# - name: id +# in: path +# description: The ID of the Challenge being previewed +### +POST /challenge/:id/priorities/preview @org.maproulette.controllers.api.ChallengeController.previewPriorities(id:Long) +### +# tags: [ Challenge ] +# operationId: challenge_read # summary: Retrieves an already existing Challenge # description: Retrieves an already existing Challenge based on the supplied ID in the URL. # responses: @@ -71,7 +167,7 @@ PUT /challenge/:id @org.maproulette.controllers # content: # application/json: # schema: -# $ref: '#/components/schemas/org.maproulette.framework.model.Challenge' +# $ref: '#/components/schemas/org.maproulette.framework.model.BaseChallenge' # '404': # description: ID field supplied but no object found matching the id # parameters: @@ -81,6 +177,7 @@ PUT /challenge/:id @org.maproulette.controllers GET /challenge/:id @org.maproulette.controllers.api.ChallengeController.read(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_readByName # summary: Retrieves an already existing Challenge # description: Retrieves an already existing Challenge based on the name of the Challenge rather than an ID # responses: @@ -103,6 +200,7 @@ GET /challenge/:id @org.maproulette.controllers GET /project/:id/challenge/:name @org.maproulette.controllers.api.ChallengeController.readByName(id:Long, name:String) ### # tags: [ Challenge ] +# operationId: challenge_delete # summary: Deletes an existing Challenge # description: Deletes an existing Challenge based on the supplied ID. This will delete all children Tasks under the Challenge. # responses: @@ -127,6 +225,7 @@ GET /project/:id/challenge/:name @org.maproulette.controllers DELETE /challenge/:id @org.maproulette.controllers.api.ChallengeController.delete(id:Long, immediate:Boolean ?= false) ### # tags: [ Challenge ] +# operationId: challenge_deletes_all_challenge_tasks # summary: Deletes all Challenge Tasks # description: Deletes all the existing tasks within a challenge. This API will also give the option to delete tasks based on the tasks current status. # So can delete all "false positive" tasks, or all "fixed and created" tasks. @@ -148,6 +247,7 @@ DELETE /challenge/:id @org.maproulette.controllers DELETE /challenge/:id/tasks @org.maproulette.controllers.api.ChallengeController.deleteTasks(id:Long, statusFilters ?= "") ### # tags: [ Challenge ] +# operationId: challenge_find # summary: Find Challenge matching search criteria # description: Finds a list of Challenges that match a specific search criteria. The search criteria is simply a string that is contained in the Challenge name. String case sensitivity is ignored. # responses: @@ -162,7 +262,7 @@ DELETE /challenge/:id/tasks @org.maproulette.controllers # parameters: # - name: q # in: query -# description: The search string used to match the Challenge names. Default value is empty string, ie. will match everything. +# description: The search string used to match the Challenge names (fuzzy/contains matching). Default value is empty string, ie. will match everything. # - name: parentId # in: query # description: This field will be ignored for this request @@ -179,6 +279,34 @@ DELETE /challenge/:id/tasks @org.maproulette.controllers GET /challenges/find @org.maproulette.controllers.api.ChallengeController.find(q:String ?= "", parentId:Long ?= -1, limit:Int ?= 10, page:Int ?= 0, onlyEnabled:Boolean ?= true) ### # tags: [ Challenge ] +# operationId: challenge_search_fuzzy +# summary: Fuzzy search for challenges by ID or name +# description: Searches for a single challenge by ID (if search string is numeric) or by name using fuzzy matching. Returns the first match if found. Supports typos and partial matches (e.g., "afri" or "afreca tourny" will find "african tourney"). +# responses: +# '200': +# description: A list containing at most one challenge matching the search criteria +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/org.maproulette.framework.model.Challenge' +# parameters: +# - name: search +# in: query +# description: The search string (can be challenge ID or name). Supports fuzzy matching for names. +# required: true +# - name: onlyEnabled +# in: query +# description: Only include enabled (discoverable) challenges +# - name: limit +# in: query +# description: Maximum number of results to return +### +GET /challenges/search @org.maproulette.controllers.api.ChallengeController.search(search:String, onlyEnabled:Boolean ?= false, limit:Int ?= 25) +### +# tags: [ Challenge ] +# operationId: challenge_extendedFind # summary: Extended Find Challenge matching search criteria # description: Finds a list of Challenges that match a specific search criteria. The search criteria is uses multiple values from the query string # responses: @@ -254,6 +382,74 @@ GET /challenges/find @org.maproulette.controllers GET /challenges/extendedFind @org.maproulette.controllers.api.ChallengeController.extendedFind(limit:Int ?= 10, page:Int ?= 0, sort:String ?= "", order:String ?= "ASC") ### # tags: [ Challenge ] +# operationId: explore_challenge_list_challenges +# summary: Explore Challenges with specific filtering and sorting +# description: Efficiently finds challenges with bounding box filtering, global toggle, sorting, and result limiting +# parameters: +# - name: global +# in: query +# description: Whether to include global challenges (default true) +# required: false +# schema: +# type: boolean +# default: true +# - name: bounds +# in: query +# description: Bounding box as comma-separated values [north,west,south,east] to filter challenges by location +# required: false +# schema: +# type: string +# example: "40.7128,-74.0060,40.7000,-74.0200" +# - name: sortBy +# in: query +# description: Column to sort results by +# required: false +# schema: +# type: string +# enum: [name, created, modified, popularity, difficulty] +# default: name +# - name: limit +# in: query +# description: Maximum number of results to return +# required: false +# schema: +# type: integer +# default: 50 +# - name: offset +# in: query +# description: Number of results to skip for pagination +# required: false +# schema: +# type: integer +# default: 0 +# - name: keywords +# in: query +# description: Comma-separated list of keywords/categories to filter challenges by +# required: false +# schema: +# type: string +# example: "roads,signs" +# - name: difficulty +# in: query +# description: Filter by difficulty (1=Easy, 2=Normal, 3=Expert) +# required: false +# schema: +# type: integer +# enum: [1, 2, 3] +# responses: +# '200': +# description: A list of Challenges matching the criteria +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/org.maproulette.framework.model.Challenge' +### +GET /challenges/exploreChallenges @org.maproulette.controllers.api.ChallengeController.exploreChallenges(global:Boolean ?= true, bounds:Option[String] ?= None, sortBy:String ?= "name", limit:Int ?= 50, offset:Int ?= 0, keywords:Option[String] ?= None, difficulty:Option[Int] ?= None) +### +# tags: [ Challenge ] +# operationId: challenge_list_challenges_in_specified_projects # summary: List challenges in specified projects # description: Retrieves a lightweight listing of challenges, with just a few basic fields for each, that belong to the specified project(s). # responses: @@ -287,6 +483,7 @@ GET /challenges/extendedFind @org.maproulette.controllers GET /challenges/listing @org.maproulette.controllers.api.ChallengeController.listing(projectIds:String ?= "", limit:Int ?= 10, page:Int ?= 0, onlyEnabled:Boolean ?= true) ### # tags: [ Challenge ] +# operationId: challenge_move_challenge_to_another_project # summary: Move Challenge to another Project # description: Will move a challenge into another project # responses: @@ -309,6 +506,7 @@ GET /challenges/listing @org.maproulette.controllers POST /challenge/:id/project/:projectId @org.maproulette.controllers.api.ChallengeController.moveChallenge(projectId:Long, id:Long) ### # tags: [ Challenge ] +# operationId: challenge_move_challenges_to_another_project # summary: Move Challenges to another Project # description: Will move a list of challenges into another project # responses: @@ -341,6 +539,7 @@ POST /challenge/:id/project/:projectId @org.maproulette.controller POST /challenges/project/:projectId @org.maproulette.controllers.api.ChallengeController.moveChallenges(projectId:Long) ### # tags: [ Challenge ] +# operationId: challenge_getTags # summary: Retrieve tags for Challenge # description: Retrieves all the Tags that have been added to the specified Challenge # responses: @@ -360,6 +559,7 @@ POST /challenges/project/:projectId @org.maproulette.controllers.a GET /challenge/:id/tags @org.maproulette.controllers.api.ChallengeController.getTagsForChallenge(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_getItemsBasedOnTags # summary: Retrieve challenges based on provided tags # description: Retrieves all the challenges that contain at least one of the supplied tags. # responses: @@ -387,6 +587,7 @@ GET /challenge/:id/tags @org.maproulette.controllers GET /challenges/tags @org.maproulette.controllers.api.ChallengeController.getItemsBasedOnTags(tags:String ?= "", limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Challenge ] +# operationId: challenge_deleteTagsFromItem # summary: Delete Challenge Tags # description: Deletes all the supplied tags from the Challenge # responses: @@ -407,6 +608,7 @@ GET /challenges/tags @org.maproulette.controllers DELETE /challenge/:id/tags @org.maproulette.controllers.api.ChallengeController.deleteTagsFromItem(id:Long, tags:String ?= "") ### # tags: [ Challenge ] +# operationId: challenge_featured_challenges # summary: Featured Challenges. # description: Get all the currently featured challenges # responses: @@ -429,6 +631,7 @@ DELETE /challenge/:id/tags @org.maproulette.controllers GET /challenges/featured @org.maproulette.controllers.api.ChallengeController.getFeaturedChallenges(limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Challenge ] +# operationId: challenge_hottest_challenges # summary: Hottest Challenges. # description: Get the hottest (recently popular) challenges # responses: @@ -451,6 +654,7 @@ GET /challenges/featured @org.maproulette.controllers GET /challenges/hot @org.maproulette.controllers.api.ChallengeController.getHotChallenges(limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Challenge ] +# operationId: challenge_preferred_challenges # summary: Preferred Challenges. # description: Get the preferred challenges which include popular, featured, and newest # responses: @@ -468,6 +672,7 @@ GET /challenges/hot @org.maproulette.controllers GET /challenges/preferred @org.maproulette.controllers.api.ChallengeController.getPreferredChallenges(limit:Int ?= 10) ### # tags: [ Challenge ] +# operationId: challenge_list # summary: List all the Challenges. # description: Lists all the Challenges in the system # responses: @@ -493,6 +698,7 @@ GET /challenges/preferred @org.maproulette.contr GET /challenges @org.maproulette.controllers.api.ChallengeController.list(limit:Int ?= 10, page:Int ?= 0, onlyEnabled:Boolean ?= false) ### # tags: [ Challenge ] +# operationId: challenge_bulk_archive_challenges # summary: Bulk Archive Challenges. # description: Archive or unarchive a list of challenges # responses: @@ -518,6 +724,7 @@ GET /challenges @org.maproulette.controllers POST /challenges/bulkArchive @org.maproulette.controllers.api.ChallengeController.bulkArchive() ### # tags: [ Challenge ] +# operationId: challenge_list_all_the_challenges_with_review_tasks # summary: List all the Challenges with review tasks. # description: Lists all the Challenges in the system with review tasks. # responses: @@ -549,6 +756,7 @@ POST /challenges/bulkArchive @org.maproulette.controllers GET /review/challenges @org.maproulette.framework.controller.TaskReviewController.listChallenges(reviewTasksType:Int, tStatus:String ?= "", excludeOtherReviewers:Boolean ?= false, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Challenge ] +# operationId: challenge_listChildren # summary: List all the Challenges Tasks. # description: Lists all the Tasks that are children of the supplied Challenge. # responses: @@ -574,6 +782,7 @@ GET /review/challenges @org.maproulette.fram GET /challenge/:id/tasks @org.maproulette.controllers.api.ChallengeController.listChildren(id:Long, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Challenge ] +# operationId: challenge_add_tasks_to_a_challenge # summary: Add tasks to a challenge # description: This will create tasks within a challenge based on the provided geojson in the body of the PUT request # responses: @@ -598,6 +807,7 @@ GET /challenge/:id/tasks @org.maproulette.controllers PUT /challenge/:id/addTasks @org.maproulette.controllers.api.ChallengeController.addTasksToChallenge(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_add_tasks_from_file # summary: Add tasks to a challenge # description: This will create tasks within a challenge based on the provided file uploaded as part of the PUT request. # responses: @@ -638,6 +848,7 @@ PUT /challenge/:id/addTasks @org.maproulette.controllers PUT /challenge/:id/addFileTasks @org.maproulette.controllers.api.ChallengeController.addTasksToChallengeFromFile(id:Long, lineByLine:Boolean ?= true, removeUnmatched:Boolean ?= false, dataOriginDate:Option[String], skipSnapshot:Boolean ?= false) ### # tags: [ Challenge ] +# operationId: challenge_retrieves_children_for_challenge # summary: Retrieves children for Challenge # description: Retrieves all the children for a Challenge in an expanded list. Unlike the GET # request /challenge/{id}/tasks, this function will wrap the json array list @@ -665,6 +876,7 @@ PUT /challenge/:id/addFileTasks @org.maproulette.controllers GET /challenge/:id/children @org.maproulette.controllers.api.ChallengeController.expandedList(id:Long, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Challenge ] +# operationId: challenge_clones_a_challenge # summary: Clones a Challenge # description: Clones a challenge # responses: @@ -689,6 +901,7 @@ GET /challenge/:id/children @org.maproulette.controllers PUT /challenge/:id/clone/:name @org.maproulette.controllers.api.ChallengeController.cloneChallenge(id:Long, name:String) ### # tags: [ Challenge ] +# operationId: challenge_rebuild_a_challenge # summary: Rebuild a Challenge # description: Rebuilds a challenge that was originally built by an overpass query or remote geojson. # responses: @@ -719,6 +932,7 @@ PUT /challenge/:id/clone/:name @org.maproulette.controllers PUT /challenge/:id/rebuild @org.maproulette.controllers.api.ChallengeController.rebuildChallenge(id:Long, removeUnmatched:Boolean ?= false, skipSnapshot:Boolean ?= false) ### # tags: [ Challenge ] +# operationId: challenge_getRandomTasks # summary: Retrieves random Task # description: Retrieves a random Task based on the search criteria and contained within the current Challenge # responses: @@ -750,6 +964,7 @@ PUT /challenge/:id/rebuild @org.maproulette.controllers GET /challenge/:cid/tasks/random @org.maproulette.controllers.api.ChallengeController.getRandomTasks(cid:Long, s:String ?= "", tags:String ?= "", limit:Int ?= 1, proximity:Long ?= -1) ### # tags: [ Challenge ] +# operationId: challenge_getRandomTasksAlias # summary: Retrieves random Task # description: Retrieves a random Task based on the search criteria and contained within the current Challenge # responses: @@ -781,6 +996,7 @@ GET /challenge/:cid/tasks/random @org.maproulette.controllers GET /challenge/:cid/tasks/randomTasks @org.maproulette.controllers.api.ChallengeController.getRandomTasks(cid:Long, s:String ?= "", tags:String ?= "", limit:Int ?= 1, proximity:Long ?= -1) ### # tags: [ Challenge ] +# operationId: challenge_getNearbyTasks # summary: Retrieves nearby Tasks # description: Retrieves tasks geographically closest to the specified task within the same Challenge # responses: @@ -809,6 +1025,7 @@ GET /challenge/:cid/tasks/randomTasks @org.maproulette.controllers GET /challenge/:cid/tasksNearby/:proximityId @org.maproulette.controllers.api.ChallengeController.getNearbyTasks(cid:Long, proximityId:Long, excludeSelfLocked:Boolean ?=false, limit:Int ?= 5) ### # tags: [ Challenge ] +# operationId: challenge_getNearbyTasksWithinBoundingBox # summary: Retrieves available Tasks within a bounding box # description: Retrieves available tasks within a bounding box # responses: @@ -874,6 +1091,7 @@ GET /challenge/:cid/nearby/box/:left/:bottom/:right/:top @org.maproulette.co GET /challenge/:cid/tasks/prioritizedTasks @org.maproulette.controllers.api.ChallengeController.getRandomTasksWithPriority(cid:Long, s:String ?= "", tags:String ?= "", limit:Int ?= 1, proximity:Long ?= -1) ### # tags: [ Challenge ] +# operationId: challenge_getSequentialNextTask # summary: Retrieves next Task # description: Retrieves the next sequential Task based on the task ordering within the Challenge. If it is currently on the last task it will response with the first task in the challenge. # responses: @@ -897,6 +1115,7 @@ GET /challenge/:cid/tasks/prioritizedTasks @org.maproulette.controllers GET /challenge/:cid/nextTask/:id @org.maproulette.controllers.api.ChallengeController.getSequentialNextTask(cid:Long, id:Long, statusList:String ?= "") ### # tags: [ Challenge ] +# operationId: challenge_getSequentialPreviousTask # summary: Retrieves previous Task # description: Retrieves the previous sequential Task based on the task ordering within the Challenge. If it is currently on the first task it will response with the last task in the challenge. # responses: @@ -920,6 +1139,7 @@ GET /challenge/:cid/nextTask/:id @org.maproulette.controllers GET /challenge/:cid/previousTask/:id @org.maproulette.controllers.api.ChallengeController.getSequentialPreviousTask(cid:Long, id:Long, statusList:String ?= "") ### # tags: [ Challenge ] +# operationId: challenge_getChallengeGeoJSON # summary: Retrieves Challenge GeoJSON # description: Retrieves the GeoJSON for the Challenge that represents all the Task children of the Challenge. # responses: @@ -962,6 +1182,7 @@ GET /challenge/:cid/previousTask/:id @org.maproulette.controllers GET /challenge/view/:id @org.maproulette.controllers.api.ChallengeController.getChallengeGeoJSON(id:Long, status:String ?= "", reviewStatus:String ?= "", priority:String ?= "", timezone:String ?= "", bbox:String ?= "", filename:String ?= "") ### # tags: [ Challenge ] +# operationId: challenge_getChallengeGeoJSONPost # summary: Retrieves Challenge GeoJSON # description: Retrieves the GeoJSON for the Challenge that represents all the Task children of the Challenge. # responses: @@ -1001,6 +1222,7 @@ GET /challenge/view/:id @org.maproulette.controllers POST /challenge/view/:id @org.maproulette.controllers.api.ChallengeController.getChallengeGeoJSON(id:Long, status:String ?= "", reviewStatus:String ?= "", priority:String ?= "", timezone:String ?= "", bbox:String ?= "", filename:String ?= "") ### # tags: [ Challenge ] +# operationId: challenge_getClusteredPoints # summary: Retrieves clustered Task points # description: Retrieves all the Tasks for a specific Challenge as clustered points to potentially display on a map # responses: @@ -1023,6 +1245,7 @@ POST /challenge/view/:id @org.maproulette.controllers GET /challenge/clustered/:id @org.maproulette.controllers.api.ChallengeController.getClusteredPoints(id:Long, filter:String ?= "", limit:Int ?= 2500, excludeLocked:Boolean ?= false) ### # tags: [ Challenge ] +# operationId: challenge_update_task_priorities # summary: Update Task Priorities # description: Updates all the Task priorities in a Challenge based on the priority rules setup in the Challenge # responses: @@ -1042,6 +1265,7 @@ GET /challenge/clustered/:id @org.maproulette.controllers PUT /challenge/:id/updateTaskPriorities @org.maproulette.controllers.api.ChallengeController.updateTaskPriorities(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_reset_task_instructions # summary: Reset Task Instructions # description: This will reset all the task instructions so that the task instructions revert to the Challenge instruction. # responses: @@ -1061,6 +1285,7 @@ PUT /challenge/:id/updateTaskPriorities @org.maproulette.controllers PUT /challenge/:id/resetTaskInstructions @org.maproulette.controllers.api.ChallengeController.resetTaskInstructions(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_retrieveComments # summary: Retrieve all comments for Challenge # description: This will retrieve all the comments for all the children tasks of a given challenge # responses: @@ -1087,6 +1312,7 @@ PUT /challenge/:id/resetTaskInstructions @org.maproulette.controllers GET /challenge/:id/comments @org.maproulette.controllers.api.ChallengeController.retrieveComments(id:Long, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Challenge ] +# operationId: challenge_retrieve_all_comments_for_challenge # summary: Retrieve all comments for Challenge # description: This will retrieve all the comments for all the children tasks of a given challenge and respond with a csv # responses: @@ -1109,6 +1335,7 @@ GET /challenge/:id/comments @org.maproulette.controllers GET /challenge/:id/comments/extract @org.maproulette.controllers.api.ChallengeController.extractComments(id:Long, limit:Int ?= -1, page:Int ?= 0) ### # tags: [ Challenge ] +# operationId: challenge_extractTaskSummaries # summary: Retrieve summaries of all tasks for Challenge # description: This will retrieve summaries of all the tasks of a given challenge and respond with a csv # responses: @@ -1149,6 +1376,7 @@ GET /challenge/:id/comments/extract @org.maproulette.controllers GET /challenge/:id/tasks/extract @org.maproulette.controllers.api.ChallengeController.extractTaskSummaries(id:Long, limit:Int ?= -1, page:Int ?= 0, status:String ?= "", reviewStatus:String ?= "", priority:String ?= "", exportProperties:String ?= "", timezone:String ?= "") ### # tags: [ Challenge ] +# operationId: challenge_retrieve_task_review_history_of_a_challenge # summary: Retrieve task review history of a Challenge # description: This will retrieve review history of all the tasks of a given challenge and respond with a csv # responses: @@ -1165,6 +1393,7 @@ GET /challenge/:id/tasks/extract @org.maproulette.controllers.ap GET /challenge/:id/extractReviewHistory @org.maproulette.controllers.api.ChallengeController.extractChallengeReviewHistory(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_extractTaskSummariesPost # summary: Retrieve summaries of all tasks for Challenge # description: This will retrieve summaries of all the tasks of a given challenge and respond with a csv # responses: @@ -1205,6 +1434,7 @@ GET /challenge/:id/extractReviewHistory @org.maproulette.control POST /challenge/:id/tasks/extract @org.maproulette.controllers.api.ChallengeController.extractTaskSummaries(id:Long, limit:Int ?= -1, page:Int ?= 0, status:String ?= "", reviewStatus:String ?= "", priority:String ?= "", exportProperties:String ?= "", timezone:String ?= "") ### # tags: [ Challenge ] +# operationId: challenge_match_osm_changesets # summary: Match OSM Changesets # description: This will go through every task and try to match an OSM changeset with the task # responses: @@ -1229,6 +1459,7 @@ POST /challenge/:id/tasks/extract @org.maproulette.controllers.a GET /challenge/:id/matchChangesets @org.maproulette.controllers.api.ChallengeController.matchChangeSets(id:Long, skipSet:Boolean ?= false) ### # tags: [ Challenge ] +# operationId: challenge_create_challenge_from_github # summary: Create Challenge from Github # description: This will pull the following files from Github, ${name}_create.json, ${name}_geojson.json, ${name}_info.md, and create a Challenge from it. The create file will be the json used to create the challenge. Similarly to if you supplied json in the create method. The info.md file is just an informational file that can be used later for challenge information to the user. And geojson.json which is used to generate the tasks. If the challenge has been previously created, it will just update the tasks from the geojson # responses: @@ -1263,6 +1494,7 @@ GET /challenge/:id/matchChangesets @org.maproulette.controllers.api POST /project/:projectId/challenge/:username/:repo/:name @org.maproulette.controllers.api.ChallengeController.createFromGithub(projectId:Long, username:String, repo:String, name:String, rebuild:Boolean ?= false) ### # tags: [ Challenge ] +# operationId: challenge_extracts_a_challenge_package # summary: Extracts a Challenge Package # description: This will retrieve a package of the challenge, which will contain json to recreate the challenge, geojson to recreate the tasks, info page in md format if any, all the comments extracted from for the challenge and any metrics and the time the challenge was extracted. # responses: @@ -1281,6 +1513,7 @@ POST /project/:projectId/challenge/:username/:repo/:name @org.maproulette. GET /challenge/:id/extract @org.maproulette.controllers.api.ChallengeController.extractPackage(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_batchUploadPost # summary: Create a batch of Challenges # deprecated: true # responses: @@ -1290,6 +1523,7 @@ GET /challenge/:id/extract @org.maproulette.controllers POST /challenges @org.maproulette.controllers.api.ChallengeController.batchUploadPost ### # tags: [ Challenge ] +# operationId: challenge_batchUploadPut # summary: Update a batch of Challenges # responses: # '200': @@ -1298,6 +1532,7 @@ POST /challenges @org.maproulette.controllers PUT /challenges @org.maproulette.controllers.api.ChallengeController.batchUploadPut ### # tags: [ Challenge ] +# operationId: challenge_undeletes_a_challenge # summary: Undeletes a Challenge # deprecated: true # responses: @@ -1307,6 +1542,7 @@ PUT /challenges @org.maproulette.controllers PUT /challenge/:id/undelete @org.maproulette.controllers.api.ChallengeController.undelete(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_createChildren # summary: Create Tasks for Challenge # deprecated: true # responses: @@ -1316,6 +1552,7 @@ PUT /challenge/:id/undelete @org.maproulette.controller POST /challenge/:id/tasks @org.maproulette.controllers.api.ChallengeController.createChildren(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_updateChildren # summary: Create Tasks for Challenge # deprecated: true # responses: @@ -1325,6 +1562,7 @@ POST /challenge/:id/tasks @org.maproulette.controllers PUT /challenge/:id/tasks @org.maproulette.controllers.api.ChallengeController.updateChildren(id:Long) ### # tags: [ Challenge ] +# operationId: challenge_update_archive_status # summary: Update archive status # description: This will update the archive status of the challenge # responses: @@ -1353,6 +1591,7 @@ PUT /challenge/:id/tasks @org.maproulette.controllers POST /challenge/:challengeId/archive @org.maproulette.controllers.api.ChallengeController.archiveChallenge(challengeId: Long) ### # tags: [ Challenge ] +# operationId: challenge_get_tag_metrics_for_challenge # summary: Get Tag Metrics for Challenge # description: This will retrieve the tag metrics for a challenge # responses: @@ -1379,5 +1618,169 @@ POST /challenge/:challengeId/archive @org.maproulette.controllers.api.Chal # default: 3 ### GET /challenge/:id/topTags @org.maproulette.controllers.api.ChallengeController.getTagMetrics(id:Long, limit:Int ?= 3) +### +# tags: [ Challenge ] +# operationId: challenge_favorite +# summary: Favorite a Challenge +# description: Favorites (saves) a challenge for the current user +# responses: +# '200': +# description: Success message +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/org.maproulette.exception.StatusMessage' +# '401': +# description: The user is not authorized to make this request +# parameters: +# - name: id +# in: path +# description: The id of the challenge to favorite +# required: true +# schema: +# type: integer +### +POST /challenge/:id/favorite @org.maproulette.controllers.api.ChallengeController.favoriteChallenge(id:Long) +### +# tags: [ Challenge ] +# operationId: challenge_unfavorite +# summary: Unfavorite a Challenge +# description: Unfavorites (unsaves) a challenge for the current user +# responses: +# '200': +# description: Success message +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/org.maproulette.exception.StatusMessage' +# '401': +# description: The user is not authorized to make this request +# parameters: +# - name: id +# in: path +# description: The id of the challenge to unfavorite +# required: true +# schema: +# type: integer +### +DELETE /challenge/:id/favorite @org.maproulette.controllers.api.ChallengeController.unfavoriteChallenge(id:Long) +### +# tags: [ Challenge ] +# operationId: challenge_is_favorited +# summary: Check if Challenge is Favorited +# description: Checks if a challenge is favorited by the current user +# responses: +# '200': +# description: Boolean indicating if the challenge is favorited +# content: +# application/json: +# schema: +# type: object +# properties: +# isFavorited: +# type: boolean +# parameters: +# - name: id +# in: path +# description: The id of the challenge to check +# required: true +# schema: +# type: integer +### +GET /challenge/:id/favorite @org.maproulette.controllers.api.ChallengeController.isChallengeFavorited(id:Long) +### +# tags: [ Challenge ] +# operationId: challenge_like +# summary: Like a Challenge +# description: Likes a challenge for the current user +# responses: +# '200': +# description: Success message +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/org.maproulette.exception.StatusMessage' +# '401': +# description: The user is not authorized to make this request +# parameters: +# - name: id +# in: path +# description: The id of the challenge to like +# required: true +# schema: +# type: integer +### +POST /challenge/:id/like @org.maproulette.controllers.api.ChallengeController.likeChallenge(id:Long) +### +# tags: [ Challenge ] +# operationId: challenge_unlike +# summary: Unlike a Challenge +# description: Unlikes a challenge for the current user +# responses: +# '200': +# description: Success message +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/org.maproulette.exception.StatusMessage' +# '401': +# description: The user is not authorized to make this request +# parameters: +# - name: id +# in: path +# description: The id of the challenge to unlike +# required: true +# schema: +# type: integer +### +DELETE /challenge/:id/like @org.maproulette.controllers.api.ChallengeController.unlikeChallenge(id:Long) +### +# tags: [ Challenge ] +# operationId: challenge_is_liked +# summary: Check if Challenge is Liked +# description: Checks if a challenge is liked by the current user +# responses: +# '200': +# description: Boolean indicating if the challenge is liked +# content: +# application/json: +# schema: +# type: object +# properties: +# isLiked: +# type: boolean +# parameters: +# - name: id +# in: path +# description: The id of the challenge to check +# required: true +# schema: +# type: integer +### +GET /challenge/:id/like @org.maproulette.controllers.api.ChallengeController.isChallengeLiked(id:Long) +### +# tags: [ Challenge ] +# operationId: challenge_like_count +# summary: Get Challenge Like Count +# description: Gets the total number of likes for a challenge +# responses: +# '200': +# description: The like count for the challenge +# content: +# application/json: +# schema: +# type: object +# properties: +# likeCount: +# type: integer +# parameters: +# - name: id +# in: path +# description: The id of the challenge +# required: true +# schema: +# type: integer +### +GET /challenge/:id/likeCount @org.maproulette.controllers.api.ChallengeController.getChallengeLikeCount(id:Long) ### NoDocs ### GET /healthCheck @org.maproulette.controllers.api.ChallengeController.healthCheck() diff --git a/conf/v2_route/changes.api b/conf/v2_route/changes.api index 1a4ea0116..5e466034f 100644 --- a/conf/v2_route/changes.api +++ b/conf/v2_route/changes.api @@ -1,5 +1,6 @@ ### # tags: [ Changes ] +# operationId: changes_test_changes # summary: Test Changes # description: Takes in a group of changes and instead of submitting them to OSM will return a standard OSMChange XML that would have been submitted to the OSM servers # responses: @@ -28,6 +29,7 @@ POST /change/tag/test @org.maproulette.controllers.OSMChangesetController.testTagChange(changeType:String ?= "delta") ### # tags: [ Changes ] +# operationId: changes_test_osm_changes_currently_only_node_creation_or_t # summary: Test OSM changes (currently only node creation or tag changes) # description: Takes in a set of changes and, instead of submitting them to OSM, will return a standard OSMChange XML that would have been submitted to the OSM servers # responses: @@ -52,6 +54,7 @@ POST /change/tag/test @org.maproulette.controllers POST /change/test @org.maproulette.controllers.OSMChangesetController.testChange() ### # tags: [ Changes ] +# operationId: changes_apply_tag_changes_for_task # summary: Apply Tag Changes for task # description: Submit a group of changes to OSM. Will return a standard OSMChange XML that has been applied to the OSM servers standard OSMChange XML that would have been submitted to the OSM servers # responses: diff --git a/conf/v2_route/comment.api b/conf/v2_route/comment.api index 6ec54b593..02811e626 100644 --- a/conf/v2_route/comment.api +++ b/conf/v2_route/comment.api @@ -1,5 +1,6 @@ ### # tags: [ Comment ] +# operationId: comment_retrieves_a_comment # summary: Retrieves a comment # description: Retrieves a comment based on a specific ID. # responses: @@ -19,6 +20,7 @@ GET /comment/:id @org.maproulette.framework.controller.CommentController.retrieve(id:Long) ### # tags: [ Comment ] +# operationId: comment_retrieves_comments_for_a_task # summary: Retrieves comments for a Task # description: Retrieves all the comments for a specific Task # responses: @@ -40,6 +42,7 @@ GET /comment/:id @org.maproulette.framework.c GET /task/:id/comments @org.maproulette.framework.controller.CommentController.find(id:Long) ### # tags: [ Comment ] +# operationId: comment_retrieves_comments_sent_by_a_user # summary: Retrieves comments sent by a User # produces: [ application/json ] # description: Retrieves all the comments sent by a User @@ -76,6 +79,33 @@ GET /task/:id/comments @org.maproulette.framework.c GET /comments/user/:id @org.maproulette.framework.controller.CommentController.findUserComments(id:Long, searchTerm:Option[String], sort:String ?= "created", order:String ?= "DESC", limit:Int ?= 25, page:Int ?= 0) ### # tags: [ Comment ] +# operationId: comment_search_task_comments +# summary: Search task comments +# description: Searches all task comments by a text search term +# responses: +# '200': +# description: A list of matching task comments +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/org.maproulette.framework.model.Comment' +# parameters: +# - name: q +# in: query +# description: The search term to match against comment text +# - name: limit +# in: query +# description: The maximum number of comments to return (default is 25) +# - name: page +# in: query +# description: The page number for pagination (default is 0) +### +GET /comments/search @org.maproulette.framework.controller.CommentController.searchComments(q:String, limit:Int ?= 25, page:Int ?= 0) +### +# tags: [ Comment ] +# operationId: comment_retrieves_comments_sent_by_user_id # summary: Retrieves comments sent by a User # produces: [ application/json ] # description: Retrieves all the challenge comments sent by a User @@ -112,6 +142,33 @@ GET /comments/user/:id @org.maproulette.framework.c GET /challengeComments/user/:id @org.maproulette.framework.controller.CommentController.findUserChallengeComments(id:Long, searchTerm:Option[String], sort:String ?= "created", order:String ?= "DESC", limit:Int ?= 25, page:Int ?= 0) ### # tags: [ Comment ] +# operationId: comment_search_challenge_comments +# summary: Search challenge comments +# description: Searches all challenge comments by a text search term +# responses: +# '200': +# description: A list of matching challenge comments +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/org.maproulette.framework.model.ChallengeComment' +# parameters: +# - name: q +# in: query +# description: The search term to match against comment text +# - name: limit +# in: query +# description: The maximum number of comments to return (default is 25) +# - name: page +# in: query +# description: The page number for pagination (default is 0) +### +GET /challengeComments/search @org.maproulette.framework.controller.CommentController.searchChallengeComments(q:String, limit:Int ?= 25, page:Int ?= 0) +### +# tags: [ Comment ] +# operationId: comment_adds_comment_to_task # summary: Adds comment to Task # description: Adds a comment to a Task # responses: @@ -150,6 +207,7 @@ GET /challengeComments/user/:id @org.maproulette.fr POST /task/:id/comment @org.maproulette.framework.controller.CommentController.add(id:Long, actionId:Option[Long]) ### # tags: [ Comment ] +# operationId: comment_adds_comment_to_each_task_in_a_task_bundle # summary: Adds comment to each Task in a Task Bundle # description: Adds a comment to each Task in Bundle # responses: @@ -188,6 +246,7 @@ POST /task/:id/comment @org.maproulette.framework.c POST /taskBundle/:id/comment @org.maproulette.framework.controller.CommentController.addToBundleTasks(id:Long, actionId:Option[Long]) ### # tags: [ Comment ] +# operationId: comment_update_comment_on_task # summary: Update comment on Task # description: Updates an existing comment on a Task. Only the original user who made the comment or a SuperUser can update the comment # responses: @@ -225,6 +284,7 @@ POST /taskBundle/:id/comment @org.maproulette.framework.c PUT /comment/:commentId @org.maproulette.framework.controller.CommentController.update(commentId:Long) ### # tags: [ Comment ] +# operationId: comment_deletes_comment_from_task # summary: Deletes comment from Task # description: Deletes a comment from the specific Task # responses: @@ -243,6 +303,7 @@ PUT /comment/:commentId @org.maproulette.framework.c DELETE /task/:id/comment/:commentId @org.maproulette.framework.controller.CommentController.delete(id:Long, commentId:Long) ### # tags: [ Comment ] +# operationId: comment_adds_comment_to_challenge # summary: Adds comment to Challenge # description: Adds a comment to a Task # responses: @@ -278,6 +339,7 @@ DELETE /task/:id/comment/:commentId @org.maproulette.framework.c POST /challenge/:id/comment @org.maproulette.framework.controller.CommentController.addChallengeComment(id:Long) ### # tags: [ Comment ] +# operationId: comment_retrieves_comments_for_a_challenge # summary: Retrieves comments for a Challenge # description: Retrieves all the challenge comments for a specific Challenge # responses: diff --git a/conf/v2_route/data.api b/conf/v2_route/data.api index dd08e1104..c64561cef 100644 --- a/conf/v2_route/data.api +++ b/conf/v2_route/data.api @@ -24,7 +24,102 @@ GET /data/status/latestActivity @org.maproulette.controllers GET /data/status/summary @org.maproulette.controllers.api.DataController.getStatusSummary(userIds:String ?= "", projectIds:String ?= "", challengeIds:String ?= "", start:String ?= "", end:String ?= "", limit:Int ?= 10, page:Int ?= 0) ### NoDocs ### GET /data/challenge/:challengeId/propertyKeys @org.maproulette.controllers.api.DataController.getPropertyKeys(challengeId:Long) -### NoDocs ### +### +# tags: [ Data ] +# operationId: data_get_challenge_summary +# summary: Get Challenge Summary Statistics +# description: Retrieves summary statistics for a challenge including action counts (fixed, false positive, skipped, etc.) and completion metrics. +# responses: +# '200': +# description: An array containing the challenge summary with action statistics +# content: +# application/json: +# schema: +# type: array +# items: +# type: object +# properties: +# id: +# type: integer +# format: int64 +# description: The challenge ID +# name: +# type: string +# description: The challenge name +# actions: +# type: object +# description: Action summary statistics +# properties: +# total: +# type: integer +# description: Total number of tasks +# available: +# type: integer +# description: Number of available tasks +# fixed: +# type: integer +# description: Number of fixed tasks +# falsePositive: +# type: integer +# description: Number of false positive tasks +# skipped: +# type: integer +# description: Number of skipped tasks +# deleted: +# type: integer +# description: Number of deleted tasks +# alreadyFixed: +# type: integer +# description: Number of already fixed tasks +# tooHard: +# type: integer +# description: Number of tasks marked as too hard +# answered: +# type: integer +# description: Number of answered tasks +# validated: +# type: integer +# description: Number of validated tasks +# disabled: +# type: integer +# description: Number of disabled tasks +# avgTimeSpent: +# type: number +# format: double +# description: Average time spent on tasks +# tasksWithTime: +# type: integer +# description: Number of tasks with time tracking data +# priorityActions: +# type: object +# description: Action summaries grouped by priority (only included if includeByPriority=true) +# '401': +# description: The user is not authorized to make this request +# '404': +# description: Challenge not found +# parameters: +# - name: challengeId +# in: path +# description: The ID of the challenge +# required: true +# schema: +# type: integer +# format: int64 +# - name: priority +# in: query +# description: Comma-separated list of priority levels to filter by (1=High, 2=Medium, 3=Low) +# required: false +# schema: +# type: string +# default: "" +# - name: includeByPriority +# in: query +# description: Whether to include action summaries broken down by priority level +# required: false +# schema: +# type: boolean +# default: false +### GET /data/challenge/:challengeId @org.maproulette.controllers.api.DataController.getChallengeSummary(challengeId:Long, priority:String ?= "", includeByPriority:Boolean ?= false) ### NoDocs ### GET /data/tag/metrics @org.maproulette.framework.controller.DataController.getTagMetrics() diff --git a/conf/v2_route/follow.api b/conf/v2_route/follow.api index 4f2626f86..8a881f1c7 100644 --- a/conf/v2_route/follow.api +++ b/conf/v2_route/follow.api @@ -1,5 +1,6 @@ ### # tags: [ Follow ] +# operationId: follow_get_users_being_followed_by_a_user # summary: Get users being followed by a user # description: Get users being followed by a user # responses: @@ -19,6 +20,7 @@ GET /user/:userId/following @org.maproulette.framework.c ### # tags: [ Follow ] +# operationId: follow_get_users_following_a_user # summary: Get users following a user # description: Get users following a user # responses: @@ -38,6 +40,7 @@ GET /user/:userId/followers @org.maproulette.framework.c ### # tags: [ Follow ] +# operationId: follow_follow_a_user # summary: Follow a user # description: Begin following a user's MapRoulette activity # responses: @@ -57,6 +60,7 @@ POST /user/:userId/follow @org.maproulette.framework.c ### # tags: [ Follow ] +# operationId: follow_stop_following_a_user # summary: Stop following a user # description: Stop following a user's MapRoulette activity # responses: @@ -76,6 +80,7 @@ DELETE /user/:userId/follow @org.maproulette.framework.c ### # tags: [ Follow ] +# operationId: follow_block_a_follower # summary: Block a follower # description: Prevent a user from following this user # responses: @@ -95,6 +100,7 @@ POST /user/:userId/block @org.maproulette.framework.c ### # tags: [ Follow ] +# operationId: follow_stop_blocking_a_follower # summary: Stop blocking a follower # description: Remove block preventing a user from following this user # responses: diff --git a/conf/v2_route/keyword.api b/conf/v2_route/keyword.api index ad637e22c..bac46eb7c 100644 --- a/conf/v2_route/keyword.api +++ b/conf/v2_route/keyword.api @@ -1,5 +1,6 @@ ### # tags: [ Tag ] +# operationId: tag_batchUploadPost # deprecated: true # responses: # '200': @@ -24,6 +25,7 @@ POST /tag @org.maproulette.framework.c PUT /tag/:id @org.maproulette.framework.controller.TagController.update(id:Long) ### # tags: [ Tag ] +# operationId: tag_batchUploadPut # deprecated: true # responses: # '200': @@ -56,6 +58,7 @@ DELETE /tag/:id @org.maproulette.framework.c GET /tags @org.maproulette.framework.controller.TagController.getTags(prefix: String ?= "", tagType: String ?= "", limit: Int ?= 10, page: Int ?= 0) ### # tags: [ Keyword ] +# operationId: tag_keyword_create_a_keyword # summary: Create a Keyword # description: Will create a new Keyword from the supplied JSON in the body. When creating the Task, leave the ID field # out of the body json, if updating (generally use the PUT method) include the ID field. @@ -83,6 +86,7 @@ GET /tags @org.maproulette.framework.c POST /keyword @org.maproulette.framework.controller.TagController.insert ### # tags: [ Keyword ] +# operationId: keyword_create_a_batch_of_keywords # summary: Create a batch of Keywords # description: Will create multiple new Keyword from the JSONArray supplied in the body. Each JSON object # is basically a Keyword object that is processed similarly to the singular /keyword POST. If @@ -109,6 +113,7 @@ POST /keyword @org.maproulette.framework.c POST /keywords @org.maproulette.framework.controller.TagController.batchUploadPost ### # tags: [ Keyword ] +# operationId: keyword_updates_a_keyword # summary: Updates a Keyword # description: Will update an already existing Keyword from the supplied JSON in the body. # responses: @@ -139,6 +144,7 @@ POST /keywords @org.maproulette.framework.c PUT /keyword/:id @org.maproulette.framework.controller.TagController.update(id:Long) ### # tags: [ Keyword ] +# operationId: keyword_update_a_batch_of_keywords # summary: Update a batch of Keywords # description: Will update multiple already existing Keywords from the JSONArray supplied in the body. Each JSON object # is basically a Keyword object that is processed similarly to the singular /keyword POST. If a Keyword @@ -165,6 +171,7 @@ PUT /keyword/:id @org.maproulette.framework.c PUT /keywords @org.maproulette.framework.controller.TagController.batchUploadPut ### # tags: [ Keyword ] +# operationId: keyword_retrieves_an_already_existing_keyword # summary: Retrieves an already existing Keyword # description: Retrieves an already existing Keyword based on the supplied ID in the URL. # responses: @@ -184,6 +191,7 @@ PUT /keywords @org.maproulette.framework.c GET /keyword/:id @org.maproulette.framework.controller.TagController.retrieve(id:Long) ### # tags: [ Keyword ] +# operationId: keyword_deletes_an_existing_keyword # summary: Deletes an existing Keyword # description: Deletes an existing Keyword based on the supplied ID. # responses: @@ -205,6 +213,7 @@ GET /keyword/:id @org.maproulette.framework.c DELETE /keyword/:id @org.maproulette.framework.controller.TagController.delete(id:Long) ### # tags: [ Keyword ] +# operationId: keyword_finds_keywords # summary: Finds Keywords # description: Retrieves existing Keywords based on a prefix for the Keyword. So if search for "tes" will retrieve all Keywords that start with "tes", like "tester", "testing", "test". The search string is case insensitive. # responses: @@ -233,6 +242,7 @@ DELETE /keyword/:id @org.maproulette.framework.c GET /keywords @org.maproulette.framework.controller.TagController.getTags(prefix: String ?= "", tagType: String ?= "", limit: Int ?= 10, page: Int ?= 0) ### # tags: [ Keyword ] +# operationId: keyword_toggle_keyword_status # summary: Toggle Keyword Status # description: Toggles a keyword's active/inactive status. Only available to super users. # responses: diff --git a/conf/v2_route/leaderboard.api b/conf/v2_route/leaderboard.api index 9e81ac138..6f998cac9 100644 --- a/conf/v2_route/leaderboard.api +++ b/conf/v2_route/leaderboard.api @@ -1,5 +1,6 @@ ### # tags: [ Leaderboard ] +# operationId: leaderboard_fetches_the_mapper_leaderboard_stats # summary: Fetches the mapper leaderboard stats # description: Fetches the mapper leaderboard stats # responses: @@ -68,6 +69,7 @@ GET /data/user/leaderboard @org.maproulette.framework.controller.LeaderboardController.getMapperLeaderboard(limit:Int ?= 20, offset:Int ?= 0) ### # tags: [ Leaderboard ] +# operationId: leaderboard_fetches_leaderboard_for_a_specific_challenge # summary: Fetches leaderboard for a specific challenge # description: Fetches the top mappers for a specific challenge within a time period # responses: @@ -137,6 +139,7 @@ GET /data/user/challengeLeaderboard @org.maproulette.framewor GET /data/user/:userId/challengeLeaderboard @org.maproulette.framework.controller.LeaderboardController.getChallengeLeaderboardForUser(userId:Int, challengeId:Int, monthDuration: Int ?= 1, bracket:Int ?= 0) ### # tags: [ Leaderboard ] +# operationId: leaderboard_fetches_leaderboard_for_a_specific_project # summary: Fetches leaderboard for a specific project # description: Fetches the top mappers for a specific project within a time period # responses: @@ -206,6 +209,7 @@ GET /data/user/projectLeaderboard @org.maproulette.framework. GET /data/user/:userId/projectLeaderboard @org.maproulette.framework.controller.LeaderboardController.getProjectLeaderboardForUser(userId:Int, projectId:Int, monthDuration: Int ?= 1, bracket:Int ?= 0) ### # tags: [ Leaderboard ] +# operationId: leaderboard_fetches_leaderboard_stats_with_ranking_for_the_use # summary: Fetches leaderboard stats with ranking for the user # description: Fetches user's current ranking and stats in the leaderboard along with a number of mappers above and below in the rankings. # responses: @@ -273,6 +277,7 @@ GET /data/user/:userId/projectLeaderboard @org.maproulette.fram GET /data/user/:userId/leaderboard @org.maproulette.framework.controller.LeaderboardController.getLeaderboardForUser(userId:Long, bracket:Int ?= 0) ### # tags: [ Leaderboard ] +# operationId: leaderboard_gets_the_top_challenges_worked_on_by_the_user # summary: Gets the top challenges worked on by the user # description: Gets the top challenges worked on by the user # responses: @@ -335,6 +340,7 @@ GET /data/user/:userId/leaderboard @org.maproulette.framework.c GET /data/user/:userId/topChallenges @org.maproulette.framework.controller.LeaderboardController.getUserTopChallenges(userId:Long, limit:Int ?= 20, offset:Int ?= 0) ### # tags: [ Leaderboard ] +# operationId: leaderboard_fetches_the_reviewer_leaderboard_stats # summary: Fetches the reviewer leaderboard stats # description: Fetches the reviewer leaderboard stats # responses: diff --git a/conf/v2_route/notification.api b/conf/v2_route/notification.api index 3e4629419..07368448e 100644 --- a/conf/v2_route/notification.api +++ b/conf/v2_route/notification.api @@ -1,5 +1,6 @@ ### # tags: [ Notification ] +# operationId: notification_retrieves_users_notifications # summary: Retrieves Users notifications # description: Retrieves notifications generated for the user # responses: @@ -42,6 +43,7 @@ GET /user/:userId/notifications @org.maproulette.framework.controller.NotificationController.getUserNotifications(userId:Long, limit:Int ?= 10, page:Int ?= 0, sort:String ?= "is_read", order:String ?= "ASC", notificationType:Option[Int], isRead:Option[Int], fromUsername:Option[String]) ### # tags: [ Notification ] +# operationId: notification_mark_user_notifications_as_read # summary: Mark user notifications as read # description: Marks user notifications as read # responses: @@ -66,6 +68,7 @@ GET /user/:userId/notifications @org.maproulette.framework.c PUT /user/:userId/notifications @org.maproulette.framework.controller.NotificationController.markNotificationsRead(userId:Long) ### # tags: [ Notification ] +# operationId: notification_mark_user_notifications_as_unread # summary: Mark user notifications as unread # description: Marks user notifications as unread # responses: @@ -90,6 +93,7 @@ PUT /user/:userId/notifications @org.maproulette.framework.c PUT /user/:userId/notifications/unread @org.maproulette.framework.controller.NotificationController.markNotificationsUnread(userId:Long) ### # tags: [ Notification ] +# operationId: notification_delete_user_notifications # summary: Delete user notifications # description: Deletes the specified user notifications # responses: @@ -114,6 +118,7 @@ PUT /user/:userId/notifications/unread @org.maproulette.framework.c PUT /user/:userId/notifications/delete @org.maproulette.framework.controller.NotificationController.deleteNotifications(userId:Long) ### # tags: [ Notification ] +# operationId: notification_retrieves_users_notification_subscriptions # summary: Retrieves Users notification subscriptions # description: Retrieves the user's subscriptions to the various notification types # responses: @@ -135,6 +140,7 @@ PUT /user/:userId/notifications/delete @org.maproulette.framewo GET /user/:userId/notificationSubscriptions @org.maproulette.framework.controller.NotificationController.getNotificationSubscriptions(userId:Long) ### # tags: [ Notification ] +# operationId: notification_updates_users_notification_subscriptions # summary: Updates user's notification subscriptions # description: Updates the user's subscriptions to various notification types # responses: @@ -157,6 +163,7 @@ GET /user/:userId/notificationSubscriptions @org.maproulette.framework.c PUT /user/:userId/notificationSubscriptions @org.maproulette.framework.controller.NotificationController.updateNotificationSubscriptions(userId:Long) ### # tags: [ Notification ] +# operationId: notification_retrieves_system_notices # summary: Retrieves System Notices # description: Retrieves system notices set up from a third party ### diff --git a/conf/v2_route/project.api b/conf/v2_route/project.api index 816028b3d..324752cb9 100644 --- a/conf/v2_route/project.api +++ b/conf/v2_route/project.api @@ -1,5 +1,6 @@ ### # tags: [ Project ] +# operationId: project_create_a_project # summary: Create a Project # description: Will create a new project from the supplied JSON in the body. When creating the # the Project, leave the ID field out of the body json, if updating (generally use the @@ -28,6 +29,7 @@ POST /project @org.maproulette.framework.controller.ProjectController.insert ### # tags: [ Project ] +# operationId: project_updates_a_project # summary: Updates a Project # description: Will update an already existing project from the supplied JSON in the body. # responses: @@ -59,6 +61,7 @@ POST /project @org.maproulette.framework.c PUT /project/:id @org.maproulette.framework.controller.ProjectController.update(id:Long) ### # tags: [ Project ] +# operationId: project_retrieves_an_already_existing_project # summary: Retrieves an already existing Project # description: Retrieves an already existing project based on the supplied ID in the URL. # responses: @@ -78,6 +81,7 @@ PUT /project/:id @org.maproulette.framework.c GET /project/:id @org.maproulette.framework.controller.ProjectController.retrieve(id:Long) ### # tags: [ Project ] +# operationId: project_retrieves_already_existing_projects_based_on_a_giv # summary: Retrieves already existing Projects based on a given list of ids # description: Retrieves already existing projects based on the supplied IDs # responses: @@ -97,6 +101,7 @@ GET /project/:id @org.maproulette.framework.c GET /projectsById @org.maproulette.framework.controller.ProjectController.retrieveList(projectIds:String) ### # tags: [ Project ] +# operationId: project_retrieves_project_by_name # summary: Retrieves an already existing Project # description: Retrieves an already existing project based on the name of the project rather than an ID # responses: @@ -116,6 +121,7 @@ GET /projectsById @org.maproulette.framework.c GET /projectByName/:name @org.maproulette.framework.controller.ProjectController.retrieveByName(name:String) ### # tags: [ Project ] +# operationId: project_deletes_an_existing_project # summary: Deletes an existing Project # description: Deletes an existing project based on the supplied ID. This will delete all the children Challenges and Tasks under the project as well. # responses: @@ -143,6 +149,7 @@ GET /projectByName/:name @org.maproulette.framework.c DELETE /project/:id @org.maproulette.framework.controller.ProjectController.delete(id:Long, immediate:Boolean ?= false) ### # tags: [ Project ] +# operationId: project_retrieve_featured_projects # summary: Retrieve featured projects # description: Get all the currently featured projects # responses: @@ -168,6 +175,7 @@ DELETE /project/:id @org.maproulette.framework.c GET /projects/featured @org.maproulette.framework.controller.ProjectController.getFeaturedProjects(onlyEnabled:Boolean ?= true, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Project ] +# operationId: project_list_all_the_projects # summary: List all the projects. # description: Lists all the projects in the system # responses: @@ -193,6 +201,34 @@ GET /projects/featured @org.maproulette.framework.c GET /projects @org.maproulette.framework.controller.ProjectController.find(search:String ?= "", limit:Int ?= 10, page:Int ?= 0, onlyEnabled:Boolean ?= false) ### # tags: [ Project ] +# operationId: project_search_fuzzy +# summary: Fuzzy search for projects by ID or name +# description: Searches for a single project by ID (if search string is numeric) or by name using fuzzy matching. Returns the first match if found. Supports typos and partial matches (e.g., "afri" or "afreca tourny" will find "african tourney"). +# responses: +# '200': +# description: A list containing at most one project matching the search criteria +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/Project' +# parameters: +# - name: search +# in: query +# description: The search string (can be project ID or name). Supports fuzzy matching for names. +# required: true +# - name: onlyEnabled +# in: query +# description: Only include enabled (discoverable) projects +# - name: limit +# in: query +# description: Maximum number of results to return +### +GET /projects/search @org.maproulette.framework.controller.ProjectController.search(search:String, onlyEnabled:Boolean ?= false, limit:Int ?= 25) +### +# tags: [ Project ] +# operationId: project_list_all_the_managed_projects # summary: List all the managed projects. # description: Lists all the managed projects in the system for the authenticated user # responses: @@ -230,6 +266,7 @@ GET /projects @org.maproulette.framework.c GET /projects/managed @org.maproulette.framework.controller.ProjectController.listManagedProjects(limit:Int ?= 10, page:Int ?= 0, onlyEnabled:Boolean ?= false, onlyOwned:Boolean ?= false, searchString:String ?= "", sort:String ?= "display_name") ### # tags: [ Project ] +# operationId: project_retrieves_clustered_challenge_points # summary: Retrieves clustered challenge points # description: Retrieves all the challenges for a specific project as clustered points to potentially display on a map # responses: @@ -256,6 +293,7 @@ GET /projects/managed @org.maproulette.framework.c GET /project/clustered/:id @org.maproulette.framework.controller.ProjectController.getClusteredPoints(id:Long, challenges:String ?= "", limit:Int ?= 0, page:Int ?= 0) ### # tags: [ Project ] +# operationId: project_retrieves_clustered_challenge_points_post # summary: Retrieves clustered challenge points # description: Retrieves all the challenges as clustered points to potentially display on a map # responses: @@ -279,6 +317,7 @@ GET /project/clustered/:id @org.maproulette.framework.c GET /project/search/clustered @org.maproulette.framework.controller.ProjectController.getSearchedClusteredPoints(limit:Int ?= 0, page:Int ?= 0) ### # tags: [ Project ] +# operationId: project_list_all_the_projects_challenges # summary: List all the projects challenges. # description: Lists all the challenges that are children of the supplied project. # responses: @@ -304,6 +343,7 @@ GET /project/search/clustered @org.maproulette.framework.c GET /project/:id/challenges @org.maproulette.framework.controller.ProjectController.listChildren(id:Long, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Project ] +# operationId: project_retrieve_all_comments_for_project # summary: Retrieve all comments for Project # description: This will retrieve all the comments of the descendent tasks of a given Project # responses: @@ -330,6 +370,7 @@ GET /project/:id/challenges @org.maproulette.framework.c GET /project/:id/comments @org.maproulette.framework.controller.ProjectController.retrieveComments(id:Long, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Project ] +# operationId: project_retrieve_summaries_of_all_tasks_in_a_project # summary: Retrieve summaries of all tasks in a Project # description: This will retrieve summaries of all the tasks of a given project and respond with a csv # responses: @@ -354,6 +395,7 @@ GET /project/:id/comments @org.maproulette.framework.con GET /project/:projectId/tasks/extract @org.maproulette.controllers.api.ChallengeController.extractAllTaskSummaries(projectId:Long, cId:Option[String], timezone:String ?= "") ### # tags: [ Project ] +# operationId: project_retrieves_random_task # summary: Retrieves random Task # description: Retrieves random tasks based on the search criteria and contained within the current project # responses: @@ -382,6 +424,7 @@ GET /project/:projectId/tasks/extract @org.maproulette.controlle GET /project/:id/tasks @org.maproulette.framework.controller.ProjectController.getRandomTasks(id:Long, limit:Int ?= 1, proximity:Long ?= -1) ### # tags: [ Project ] +# operationId: project_find_project_matching_search_criteria_use_get_proj # summary: Find project matching search criteria. Use GET /projects to find instead. # deprecated: true # responses: diff --git a/conf/v2_route/review.api b/conf/v2_route/review.api index e5d61ff37..69ad110ad 100644 --- a/conf/v2_route/review.api +++ b/conf/v2_route/review.api @@ -1,5 +1,6 @@ ### # tags: [ Review ] +# operationId: review_retrieves_and_claims_a_review_needed_task # summary: Retrieves and claims a review needed Task # description: Retrieves a Task and claims that task for review # responses: @@ -17,6 +18,7 @@ GET /task/:id/review/start @org.maproulette.framework.controller.TaskReviewController.startTaskReview(id:Long) ### # tags: [ Review ] +# operationId: review_cancels_a_claim_on_a_task_for_review # summary: Cancels a claim on a task for review # description: Cancels a claim on a task for review # responses: @@ -34,6 +36,7 @@ GET /task/:id/review/start @org.maproulette.framework. GET /task/:id/review/cancel @org.maproulette.framework.controller.TaskReviewController.cancelTaskReview(id:Long) ### # tags: [ Review ] +# operationId: review_retrieves_tasks_that_need_review # summary: Retrieves tasks that need review # description: Retrieves list of Tasks and total count # responses: @@ -80,6 +83,7 @@ GET /task/:id/review/cancel @org.maproulette.framework GET /tasks/review @org.maproulette.framework.controller.TaskReviewController.getReviewRequestedTasks(onlySaved: Boolean ?= false, limit:Int ?= -1, page:Int ?= 0, sort:String ?= "", order:String ?= "ASC", excludeOtherReviewers: Boolean ?= false, includeTags: Boolean ?= false) ### # tags: [ Review ] +# operationId: review_retrieves_reviewed_tasks_that_have_been_reviewed_e # summary: Retrieves reviewed tasks that have been reviewed either by this user or where the user requested # the review. # description: Retrieves list of Tasks @@ -135,6 +139,7 @@ GET /tasks/review @org.maproulette.framework.contro GET /tasks/reviewed @org.maproulette.framework.controller.TaskReviewController.getReviewedTasks(allowReviewNeeded:Boolean ?= false, limit:Int ?= 10, page:Int ?= 0, sort:String ?= "", order:String ?= "ASC", includeTags:Boolean ?= false, asMetaReview: Boolean ?= false) ### # tags: [ Review ] +# operationId: review_retrieves_and_claims_a_the_next_review_needed_task # summary: Retrieves and claims a the next review needed Task # description: Retrieves the next Task (given the search parameters) and claims that task for review # responses: @@ -176,6 +181,7 @@ GET /tasks/reviewed @org.maproulette.framework.cont GET /tasks/review/next @org.maproulette.framework.controller.TaskReviewController.nextTaskReview(onlySaved:Boolean ?= false, sort:String ?= "", order:String ?= "ASC", lastTaskId:Long ?= -1, excludeOtherReviewers:Boolean ?= false, asMetaReview: Boolean ?= false) ### # tags: [ Review ] +# operationId: review_retrieves_nearby_tasks # summary: Retrieves nearby Tasks # description: Retrieves review tasks geographically closest to the specified task within the given filters # responses: @@ -204,6 +210,7 @@ GET /tasks/review/next @org.maproulette.framework.cont GET /tasks/review/nearby/:proximityId @org.maproulette.framework.controller.TaskReviewController.getNearbyReviewTasks(proximityId:Long, limit:Int ?= 5, excludeOtherReviewers:Boolean ?= false, onlySaved:Boolean ?= false) ### # tags: [ Review ] +# operationId: review_retrieves_reviewed_tasks_by_user # summary: Retrieves tasks that need review # description: Retrieves list of Tasks and total count # responses: @@ -250,6 +257,7 @@ GET /tasks/review/nearby/:proximityId @org.maproulette.framework.c GET /tasks/review/metrics @org.maproulette.framework.controller.TaskReviewMetricsController.getReviewMetrics(reviewTasksType: Int, onlySaved:Boolean ?= false, excludeOtherReviewers: Boolean ?= false, includeByPriority:Boolean ?= false, includeByTaskStatus:Boolean ?= false) ### # tags: [ Review ] +# operationId: review_retrieves_task_review_clusters # summary: Retrieves task review clusters # description: Retrieves task clusters that contain the centroid location for a group of review tasks # responses: @@ -284,6 +292,7 @@ GET /tasks/review/metrics @org.maproulette.framewor GET /taskCluster/review @org.maproulette.framework.controller.TaskReviewController.getReviewTaskClusters(reviewTasksType:Int, points:Int ?= 100, onlySaved:Boolean ?= false, excludeOtherReviewers:Boolean ?= false) ### # tags: [ Review ] +# operationId: review_retrieve_a_summary_of_review_coverage_for_mappers # summary: Retrieve a summary of review coverage for mappers # description: This will retrieve a summary of review coverage for each mapper and respond with a csv # responses: @@ -312,6 +321,7 @@ GET /taskCluster/review @org.maproulette.fram GET /tasks/review/mappers/export @org.maproulette.framework.controller.TaskReviewMetricsController.extractMapperMetrics(onlySaved:Boolean ?= false) ### # tags: [ Review ] +# operationId: review_retrieve_a_summary_of_review_coverage_for_review_r # summary: Retrieve a summary of review coverage for review related tasks # description: This will retrieve a summary of review coverage for each review related task and respond with a csv # response: @@ -379,6 +389,7 @@ GET /tasks/review/mappers/export @org.maproulette.f GET /tasks/review/reviewTable/export @org.maproulette.framework.controller.TaskReviewController.extractReviewTableData(taskId: String ?= "", featureId: String ?= "", reviewStatus: String ?= "0,1,2,3,4,5,6,7,-1", mapper: String ?= "", challengeId: String ?= "", projectId: String ?= "", mappedOn: String ?= "", reviewedBy: String ?= "", reviewedAt: String ?= "", metaReviewedBy: String ?= "", metaReviewStatus: String ?= "2,0,1,2,3,6", status: String ?= "0,1,2,3,4,5,6,9", priority: String ?= "0,1,2", tagFilter: String ?= "", sortBy: String ?= "mapped_on", direction: String ?= "ASC", displayedColumns: String ?= "Internal Id,Review Status,Mapper,Challenge,Project,Mapped On,Reviewer,Reviewed On,Status,Priority,Actions,Additional Reviewers", invertedFilters: String ?= "", onlySaved: Boolean ?= false) ### # tags: [ Review ] +# operationId: review_retrieve_a_summary_of_meta-review_coverage_for_rev # summary: Retrieve a summary of meta-review coverage for reviewers # description: This will retrieve a summary of meta-review coverage for each reviewer and respond with a csv # responses: @@ -404,6 +415,7 @@ GET /tasks/review/reviewTable/export @org.maproulette.framework GET /tasks/metareview/reviewers/export @org.maproulette.framework.controller.TaskReviewMetricsController.extractMetaReviewCoverage(onlySaved:Boolean ?= false) ### # tags: [ Review ] +# operationId: review_retrieves_metrics_grouped_by_tag # summary: Retrieves metrics grouped by tag # description: Retrieves list of Tasks and total count # responses: diff --git a/conf/v2_route/search.api b/conf/v2_route/search.api new file mode 100644 index 000000000..89d5c3afc --- /dev/null +++ b/conf/v2_route/search.api @@ -0,0 +1,18 @@ +### +# tags: [ Search ] +# operationId: unified_search +# summary: Unified search across projects, challenges, and tasks by name +# responses: +# '200': +# description: Search results grouped by type +### +GET /search @org.maproulette.framework.controller.SearchController.search(q:String, limit:Int ?= 25) +### +# tags: [ Search ] +# operationId: unified_search_by_id +# summary: Look up a project, challenge, and task by ID +# responses: +# '200': +# description: Matching items by ID +### +GET /search/byId @org.maproulette.framework.controller.SearchController.searchById(id:Long) diff --git a/conf/v2_route/service.api b/conf/v2_route/service.api index fe33a1c19..c5223d8c8 100644 --- a/conf/v2_route/service.api +++ b/conf/v2_route/service.api @@ -1,5 +1,6 @@ ### # tags: [ Service ] +# operationId: service_retrieves_service_information_such_as_the_version_ # summary: Retrieves service information such as the version and compiler version # description: Retrieves service information such as the version and compiler version # responses: diff --git a/conf/v2_route/snapshot.api b/conf/v2_route/snapshot.api index 644269d0e..14a073d87 100644 --- a/conf/v2_route/snapshot.api +++ b/conf/v2_route/snapshot.api @@ -1,5 +1,6 @@ ### # tags: [ Snapshot ] +# operationId: snapshot_records_a_snapshot_for_a_challenge # summary: Records a snapshot for a challenge # description: Creates a challenge snapshot. # responses: @@ -13,6 +14,7 @@ GET /snapshot/challenge/:challengeId/record @org.maproulette.framework.controller.ChallengeSnapshotController.recordChallengeSnapshot(challengeId:Long) ### # tags: [ Snapshot ] +# operationId: snapshot_creates_a_csv_of_all_snapshots_for_a_challenge # summary: Creates a csv of all snapshots for a challenge. # description: Creates a csv export of all snaphshots for a challenge. # responses: @@ -26,6 +28,7 @@ GET /snapshot/challenge/:challengeId/record @org.maproulette.framework.c GET /snapshot/challenge/:challengeId/export @org.maproulette.framework.controller.ChallengeSnapshotController.exportChallengeSnapshots(challengeId:Long) ### # tags: [ Snapshot ] +# operationId: snapshot_gets_a_snapshot # summary: Gets a snapshot # description: Retrieves a challenge snapshot # responses: @@ -39,6 +42,7 @@ GET /snapshot/challenge/:challengeId/export @org.maproulette.framework.c GET /snapshot/:snapshotId @org.maproulette.framework.controller.ChallengeSnapshotController.retrieve(snapshotId:Long) ### # tags: [ Snapshot ] +# operationId: snapshot_deletes_a_snapshot # summary: Deletes a snapshot # description: Deletes a challenge snapshot # responses: @@ -52,6 +56,7 @@ GET /snapshot/:snapshotId @org.maproulette.framework.c DELETE /snapshot/:snapshotId @org.maproulette.framework.controller.ChallengeSnapshotController.delete(snapshotId:Long) ### # tags: [ Snapshot ] +# operationId: snapshot_gets_the_list_of_snapshots_for_a_challenge # summary: Gets the list of snapshots for a challenge # description: Retrieves a list challenge snapshots # responses: diff --git a/conf/v2_route/task.api b/conf/v2_route/task.api index e43e87fd2..d211a5c99 100644 --- a/conf/v2_route/task.api +++ b/conf/v2_route/task.api @@ -1,5 +1,6 @@ ### # tags: [ Task ] +# operationId: task_retrieves_a_history_for_the_task # summary: Retrieves a history for the task # description: Retrieves list of task history log entries. This includes comments, # status actions, and review actions. @@ -18,6 +19,7 @@ GET /task/:id/history @org.maproulette.framework.controller.TaskHistoryController.getTaskHistoryLog(id:Long) ### # tags: [ Task ] +# operationId: task_create_a_task # summary: Create a Task # description: Will create a new Task from the supplied JSON in the body. When creating the Task, leave the ID field # out of the body json, if updating (generally use the PUT method) include the ID field. @@ -45,6 +47,7 @@ GET /task/:id/history @org.maproulette.framework.contr POST /task @org.maproulette.controllers.api.TaskController.create ### # tags: [ Task ] +# operationId: task_create_a_batch_of_tasks # summary: Create a batch of Tasks # description: Will create multiple new Tasks from the JSONArray supplied in the body. Each JSON object # is basically a Task object that is processed similarly to the singular /sask POST. If @@ -71,6 +74,7 @@ POST /task @org.maproulette.controllers POST /tasks @org.maproulette.controllers.api.TaskController.batchUploadPost ### # tags: [ Task ] +# operationId: task_updates_a_task # summary: Updates a Task # description: Will update an already existing Task from the supplied JSON in the body. # responses: @@ -101,6 +105,7 @@ POST /tasks @org.maproulette.controllers PUT /task/:id @org.maproulette.controllers.api.TaskController.update(id:Long) ### # tags: [ Task ] +# operationId: task_update_a_batch_of_tasks # summary: Update a batch of Tasks # description: Will update multiple already existing Tasks from the JSONArray supplied in the body. Each JSON object # is basically a Task object that is processed similarly to the singular /task POST. If a Task @@ -127,6 +132,7 @@ PUT /task/:id @org.maproulette.controllers PUT /tasks @org.maproulette.controllers.api.TaskController.batchUploadPut ### # tags: [ Task ] +# operationId: task_changes_status_on_tasks_matching_criteria # summary: Changes status on tasks matching criteria # description: Will changes status on tasks that match the given search parameters. # responses: @@ -138,6 +144,87 @@ PUT /tasks @org.maproulette.controllers PUT /tasks/changeStatus @org.maproulette.controllers.api.TaskController.bulkStatusChange(newStatus:Int) ### # tags: [ Task ] +# operationId: task_bulk_delete_tasks +# summary: Bulk delete tasks by id list +# description: Deletes every task in the supplied `taskIds` array. Callers must +# have write access on the parent project for each affected task; +# any task for which access is denied is skipped and reported in +# the response. +# responses: +# '200': +# description: Deletion summary +# requestBody: +# description: JSON body containing taskIds +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# taskIds: +# type: array +# items: +# type: integer +# format: int64 +### +DELETE /tasks @org.maproulette.controllers.api.TaskController.bulkDelete +### +# tags: [ Task ] +# operationId: task_bulk_archive_tasks +# summary: Bulk archive/unarchive tasks by id list +# description: Toggles the archived flag on every task in `taskIds`. Archived +# tasks are excluded from task selection for mappers but remain in +# the database. +# responses: +# '204': +# description: All tasks updated +# requestBody: +# description: JSON body containing taskIds + archived +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# taskIds: +# type: array +# items: +# type: integer +# format: int64 +# archived: +# type: boolean +### +PUT /tasks/archive @org.maproulette.controllers.api.TaskController.bulkArchive +### +# tags: [ Task ] +# operationId: task_bulk_reassign_tasks +# summary: Bulk reassign task review to a different user +# description: Reassigns the review of every task in `taskIds` to the supplied +# `userId`. Tasks without an active review remain unchanged. +# responses: +# '204': +# description: All tasks updated +# requestBody: +# description: JSON body containing taskIds + userId +# required: true +# content: +# application/json: +# schema: +# type: object +# properties: +# taskIds: +# type: array +# items: +# type: integer +# format: int64 +# userId: +# type: integer +# format: int64 +### +PUT /tasks/reassign @org.maproulette.controllers.api.TaskController.bulkReassign +### +# tags: [ Task ] +# operationId: task_retrieves_an_already_existing_task # summary: Retrieves an already existing Task # description: Retrieves an already existing Task based on the supplied ID in the URL. # responses: @@ -157,6 +244,32 @@ PUT /tasks/changeStatus @org.maproulette.controllers. GET /task/:id @org.maproulette.controllers.api.TaskController.read(id:Long) ### # tags: [ Task ] +# operationId: task_retrieves_multiple_tasks +# summary: Retrieves multiple Tasks by their IDs +# description: Retrieves multiple Tasks based on the supplied array of task IDs as query parameters (comma-separated). +# responses: +# '200': +# description: Array of retrieved Tasks +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/org.maproulette.framework.model.Task' +# '400': +# description: Invalid request parameters +# parameters: +# - name: taskIds +# in: query +# description: Comma-separated list of task IDs to retrieve +# required: true +# schema: +# type: string +### +GET /tasks @org.maproulette.controllers.api.TaskController.getTasks(taskIds:String) +### +# tags: [ Task ] +# operationId: task_start_working_on_a_task_locks_it_for_the_user # summary: Start working on a Task (locks it for the user) # description: Locks a Task based on the supplied ID in the URL. # responses: @@ -177,6 +290,7 @@ GET /task/:id/start @org.maproulette.controlle ### # tags: [ Task ] +# operationId: task_retrieve_any_change_xml_that_is_part_of_this_tasks # summary: Retrieve any change XML that is part of this task's cooperative work # description: Retrieve change XML that is part of this task's cooperative work. # The cooperative work on the task should be consulted to determine @@ -206,6 +320,7 @@ GET /task/:id/cooperative/change/$filename<\w[\w\d-_\.]*> @org.ma ### # tags: [ Task ] +# operationId: task_retrieve_task_attachment # summary: Retrieve task attachment # description: Retrieve attachment identified by attachmentId on specified task # @@ -226,6 +341,7 @@ GET /task/:id/attachment/$attachmentId<[\w\d-_~\.]+> @org.ma ### # tags: [ Task ] +# operationId: task_download_task_attachment_data_as_file # summary: Download task attachment data as file # description: Download attachment attachment data as file # @@ -252,6 +368,7 @@ GET /task/:id/attachment/$attachmentId<[\w\d-_~\.]+>/data/$filename<[\w\d-_\ ### # tags: [ Task ] +# operationId: task_release_a_task_unlocks_it # summary: Release a Task (unlocks it) # description: Unlocks a Task based on the supplied ID in the URL. # responses: @@ -271,6 +388,27 @@ GET /task/:id/attachment/$attachmentId<[\w\d-_~\.]+>/data/$filename<[\w\d-_\ GET /task/:id/release @org.maproulette.controllers.api.TaskController.releaseTask(id:Long) ### # tags: [ Task ] +# operationId: task_skip_a_task +# summary: Skip a Task (increments skip count, releases lock, preserves status) +# description: Records a skip event for the task (incrementing skip_count), +# releases any lock the caller owns, and leaves the task's status +# unchanged. Use this instead of transitioning the task to the +# Skipped status when the user does not want to act on the task +# right now. +# responses: +# '204': +# description: The skip event was recorded and the lock was released +# '404': +# description: ID field supplied but no Task found matching the id +# parameters: +# - name: id +# in: path +# description: The id of the Task to skip +### +POST /task/:id/skip @org.maproulette.controllers.api.TaskController.skipTask(id:Long) +### +# tags: [ Task ] +# operationId: task_refresh_an_existing_lock_on_a_task # summary: Refresh an existing lock on a Task # description: Refreshes an existing lock, extending its allowed duration, on the # task with the supplied ID. The requesting user must already own an @@ -295,6 +433,7 @@ GET /task/:id/release @org.maproulette.control GET /task/:id/refreshLock @org.maproulette.controllers.api.TaskController.refreshTaskLock(id:Long) ### # tags: [ Task ] +# operationId: task_retrieves_task_by_name # summary: Retrieves an already existing Task # description: Retrieves an already existing Task based on the name of the Task rather than an ID # responses: @@ -317,6 +456,7 @@ GET /task/:id/refreshLock @org.maproulette.control GET /challenge/:id/task/:name @org.maproulette.controllers.api.TaskController.readByName(id:Long, name:String) ### # tags: [ Task ] +# operationId: task_deletes_an_existing_task # summary: Deletes an existing Task # description: Deletes an existing Task based on the supplied ID. # responses: @@ -338,6 +478,7 @@ GET /challenge/:id/task/:name @org.maproulette.controllers DELETE /task/:id @org.maproulette.controllers.api.TaskController.delete(id:Long, immediate:Boolean ?= true) ### # tags: [ Task ] +# operationId: task_find_task_matching_search_criteria # summary: Find Task matching search criteria # description: Finds a list of Tasks that match a specific search criteria. The search criteria is simply a string that is contained in the Task name. String case sensitivity is ignored. # responses: @@ -369,6 +510,16 @@ DELETE /task/:id @org.maproulette.controllers GET /tasks/find @org.maproulette.controllers.api.TaskController.find(q:String ?= "", parentId:Long ?= -1, limit:Int ?= 10, page:Int ?= 0, onlyEnabled:Boolean ?= true) ### # tags: [ Task ] +# operationId: task_search +# summary: Search for tasks by name +# responses: +# '200': +# description: A list of tasks matching the search string +### +GET /tasks/search @org.maproulette.controllers.api.TaskController.search(q:String, limit:Int ?= 25) +### +# tags: [ Task ] +# operationId: task_retrieve_tags_for_task # summary: Retrieve tags for Task # description: Retrieves all the Tags that have been added to the specified Task # responses: @@ -388,6 +539,7 @@ GET /tasks/find @org.maproulette.controllers GET /task/:id/tags @org.maproulette.controllers.api.TaskController.getTagsForTask(id:Long) ### # tags: [ Task ] +# operationId: task_retrieve_tasks_based_on_provided_tags # summary: Retrieve Tasks based on provided tags # description: Retrieves all the Tasks that contain at least one of the supplied tags. # responses: @@ -415,6 +567,7 @@ GET /task/:id/tags @org.maproulette.controllers GET /tasks/tags @org.maproulette.controllers.api.TaskController.getItemsBasedOnTags(tags:String ?= "", limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Task ] +# operationId: task_updates_task_tags # summary: Updates Task Tags # description: Updates the tags on the Task # responses: @@ -436,6 +589,7 @@ GET /tasks/tags @org.maproulette.controllers GET /task/:id/tags/update @org.maproulette.controllers.api.TaskController.updateItemTags(id:Long, tags:String ?= "") ### # tags: [ Task ] +# operationId: task_delete_task_tags # summary: Delete Task Tags # description: Deletes all the supplied tags from the Task # responses: @@ -457,6 +611,7 @@ GET /task/:id/tags/update @org.maproulette.control DELETE /task/:id/tags @org.maproulette.controllers.api.TaskController.deleteTagsFromItem(id:Long, tags:String ?= "") ### # tags: [ Task ] +# operationId: task_retrieves_random_task # summary: Retrieves random Task # description: Retrieves a random Task based on the search criteria. # responses: @@ -494,6 +649,7 @@ DELETE /task/:id/tags @org.maproulette.controllers GET /tasks/random @org.maproulette.controllers.api.TaskController.getRandomTasks(ps:String ?= "", cs:String ?= "", ct:String ?= "", tags:String ?= "", ts:String ?= "", limit:Int ?= 1, proximity:Long ?= -1) ### # tags: [ Task ] +# operationId: task_retrieves_tasks_within_a_bounding_box # summary: Retrieves Tasks within a bounding box # description: Retrieves tasks within a given bounding box. # responses: @@ -537,6 +693,7 @@ GET /tasks/random @org.maproulette.controllers PUT /tasks/box/:left/:bottom/:right/:top @org.maproulette.framework.controller.TaskController.getTasksInBoundingBox(left:Double, bottom:Double, right:Double, top:Double, limit:Int ?= 10000, page:Int ?= 0, excludeLocked:Boolean ?= false, sort:String ?= "", order:String ?= "ASC", includeTotal:Boolean ?= false, includeGeometries:Boolean ?=false, includeTags:Boolean ?= false) ### # tags: [ Task ] +# operationId: task_retrieves_task_marker_data_within_a_bounding_box # summary: Retrieves Task Marker Data within a bounding box # description: Retrieves task marker data within a given bounding box. # responses: @@ -574,6 +731,83 @@ PUT /tasks/box/:left/:bottom/:right/:top @org.maproulette.framework.c PUT /markers/box/:left/:bottom/:right/:top @org.maproulette.framework.controller.TaskController.getTaskMarkerDataInBoundingBox(left:Double, bottom:Double, right:Double, top:Double, limit:Int ?= 500, excludeLocked:Boolean ?= false, includeGeometries:Boolean ?= false, includeTags:Boolean ?= false) ### # tags: [ Task ] +# operationId: task_marker_Data +# summary: Retrieves task markers for map display +# description: Returns task markers, overlapping marker groups, or cluster summaries depending on the number of matching tasks. If totalCount exceeds 5000, neither tasks nor clusters are returned. Between 100 and 5000, clusters are returned. Under 100, individual markers and overlaps are returned. +# responses: +# '200': +# description: Task marker response with totalCount and optional tasks/clusters +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/org.maproulette.framework.model.TaskMarkerResponse' +# parameters: +# - name: statuses +# in: query +# description: Comma-separated task statuses to include (e.g. "0,3,6") +# - name: global +# in: query +# description: Whether to include only global challenges +# - name: cluster +# in: query +# description: Whether to force cluster mode +# - name: bounds +# in: query +# description: Comma-separated bounding box (left,bottom,right,top) +# - name: keywords +# in: query +# description: Optional keyword filter +# - name: difficulty +# in: query +# description: Optional difficulty filter (1=Easy, 2=Normal, 3=Expert) +### +GET /taskMarkers @org.maproulette.framework.controller.TaskController.getTaskMarkers(statuses:String ?= "", global:Boolean ?= false, cluster:Boolean ?= false, bounds:Option[String], keywords:Option[String], difficulty:Option[Int]) +### +# tags: [ Task ] +# operationId: task_get_task_tiles +# summary: Get Task Tiles as MVT +# description: Returns a Mapbox Vector Tile (MVT) for the given tile coordinates. Features include group_type (0=single, 1=overlap, 2=cluster), task_count, and task properties for singles. +# responses: +# '200': +# description: Binary MVT data +# content: +# application/vnd.mapbox-vector-tile: +# schema: +# type: string +# format: binary +# parameters: +# - name: z +# in: path +# required: true +# schema: +# type: integer +# - name: x +# in: path +# required: true +# schema: +# type: integer +# - name: y +# in: path +# required: true +# schema: +# type: integer +# - name: global +# in: query +# required: false +# schema: +# type: boolean +# default: false +# - name: difficulty +# in: query +# required: false +# schema: +# type: integer +# enum: [1, 2, 3] +### +GET /taskTilesMvt/:z/:x/:y @org.maproulette.framework.controller.TaskController.getTaskTilesMvt(z: Int, x: Int, y: Int, global: Boolean ?= false, difficulty: Option[Int] ?= None, keywords: Option[String] ?= None) +### +# tags: [ Task ] +# operationId: task_update_task_changeset # summary: Update Task Changeset # description: Will update the changeset of the task. It will do this by attempting to match the OSM changeset to the Task based on the geometry and the time that the changeset was executed. # responses: @@ -595,6 +829,7 @@ PUT /markers/box/:left/:bottom/:right/:top @org.maproulette.framework PUT /task/:id/changeset @org.maproulette.controllers.api.TaskController.matchToOSMChangeSet(id:Long) ### # tags: [ Task ] +# operationId: task_update_completion_responses # summary: Update Completion Responses # description: Will update the completion responses on the Task. # responses: @@ -618,6 +853,7 @@ PUT /task/:id/changeset @org.maproulette.controllers PUT /task/:id/responses @org.maproulette.framework.controller.TaskController.updateCompletionResponses(id:Long) ### # tags: [ Task ] +# operationId: task_update_task_status # summary: Update Task Status # description: Will update a Tasks status to one of the following. 0 - Created, 1 - Fixed, 2 - False Positive, 3 - Skipped, 4 - Deleted, 5 - Already Fixed, 6 - Can't Complete # responses: @@ -651,6 +887,7 @@ PUT /task/:id/responses @org.maproulette.framework PUT /task/:id/:status @org.maproulette.controllers.api.TaskController.setTaskStatus(id:Long, status:Int, tags:String ?="") ### # tags: [ Task ] +# operationId: task_update_bundle_task_status # summary: Update Bundle Task Status # description: Will update a Bundled list of Tasks statuses to one of the following. 0 - Created, 1 - Fixed, 2 - False Positive, 3 - Skipped, 4 - Deleted, 5 - Already Fixed, 6 - Can't Complete # responses: @@ -679,6 +916,7 @@ PUT /task/:id/:status @org.maproulette.controllers PUT /taskBundle/:bundleId/:status @org.maproulette.framework.controller.TaskBundleController.setBundleTaskStatus(bundleId:Long, primaryId:Long, status:Int, tags:String ?="") ### # tags: [ Task ] +# operationId: task_update_task_review_status # summary: Update Task Review Status # description: Will update a Tasks review status to one of the following. 0 - Requested, 1 - Approved, 2 - Rejected, 3 - Assisted # responses: @@ -722,6 +960,7 @@ PUT /taskBundle/:bundleId/:status @org.maproulette PUT /task/:id/review/:status @org.maproulette.framework.controller.TaskReviewController.setTaskReviewStatus(id:Long, status:Int, tags:String ?="", newTaskStatus:String ?= "", errorTags:String ?= "") ### # tags: [ Task ] +# operationId: task_changes_review_status_to_unnecessary_on_tasks_matc # summary: Changes review status to "Unnecessary" on tasks matching criteria # description: Will change review status on tasks that match the given search parameters # indicating the tasks do not need a review. @@ -738,6 +977,7 @@ PUT /task/:id/review/:status @org.maproulette.fram PUT /tasks/review/remove @org.maproulette.framework.controller.TaskReviewController.removeReviewRequest(ids:String?="", asMetaReview:Boolean ?= false) ### # tags: [ Task ] +# operationId: task_update_task_review_status_for_a_bundle # summary: Update Task Review Status for a Bundle # description: Will update a Tasks review status to one of the following. 0 - Requested, 1 - Approved, 2 - Rejected, 3 - Assisted # responses: @@ -778,6 +1018,7 @@ PUT /tasks/review/remove @org.maproulette.fram PUT /taskBundle/:id/review/:status @org.maproulette.framework.controller.TaskBundleController.setBundleTaskReviewStatus(id:Long, status:Int, tags:String ?="", newTaskStatus:String ?= "", errorTags:String ?= "") ### # tags: [ Task ] +# operationId: task_update_task_meta_review_status # summary: Update Task Meta Review Status # description: Will update a Tasks meta review status to one of the following. 0 - (re)Requested, 1 - Approved, 2 - Rejected, 3 - Assisted # responses: @@ -817,6 +1058,7 @@ PUT /taskBundle/:id/review/:status @org.maproulette.fram PUT /task/:id/metareview/:status @org.maproulette.framework.controller.TaskReviewController.setMetaReviewStatus(id:Long, status:Int, tags:String ?="", errorTags:String ?= "") ### # tags: [ Task ] +# operationId: task_update_meta_review_status_for_a_bundle # summary: Update Meta Review Status for a Bundle # description: Will update Tasks meta review status to one of the following. 0 - Requested, 1 - Approved, 2 - Rejected, 3 - Assisted # responses: @@ -856,6 +1098,7 @@ PUT /task/:id/metareview/:status @org.maproulette. PUT /taskBundle/:id/metareview/:status @org.maproulette.framework.controller.TaskBundleController.setBundleMetaReviewStatus(id:Long, status:Int, tags:String ?="", errorTags:String ?= "") ### # tags: [ Task ] +# operationId: task_retrieves_task_clusters # summary: Retrieves task clusters. # description: Retrieves task clusters that contain the centroid location for a group of tasks # responses: @@ -875,6 +1118,7 @@ PUT /taskBundle/:id/metareview/:status @org.maproulette. PUT /taskCluster @org.maproulette.framework.controller.TaskController.getTaskClusters(points:Int ?= 100) ### # tags: [ Task ] +# operationId: task_retrieves_tasks_in_a_cluster # summary: Retrieves tasks in a cluster # description: Retrieves tasks contained in a cluster retrieved from api /api/v2/challenge/:id/taskCluster # responses: @@ -900,6 +1144,7 @@ PUT /taskCluster @org.maproulette.framework.c GET /tasksInCluster/:clusterId @org.maproulette.framework.controller.TaskController.getTasksInCluster(clusterId:Int, points:Int ?= 100) ### # tags: [ Task ] +# operationId: task_retrieves_tasks_within_a_bounding_box_deprecated # summary: Retrieves Tasks within a bounding box # deprecated: true # responses: @@ -909,6 +1154,66 @@ GET /tasksInCluster/:clusterId @org.maproulette.framework.c GET /tasks/box/:left/:bottom/:right/:top @org.maproulette.framework.controller.TaskController.getTasksInBoundingBox(left:Double, bottom:Double, right:Double, top:Double, limit:Int ?= 10000, page:Int ?= 0, excludeLocked:Boolean ?= false, sort:String ?= "", order:String ?= "ASC", includeTotal:Boolean ?= false, includeGeometries:Boolean ?=false, includeTags:Boolean ?= false) ### # tags: [ Task ] +# operationId: task_get_challenge_tasks_in_bounds +# summary: Get challenge tasks in bounding box +# description: Retrieves tasks within a bounding box for specified challenges. This is a simplified endpoint that returns paginated task data based on the visible map area. Useful for displaying tasks in a table or list view based on the current map viewport. +# responses: +# '200': +# description: Paginated list of tasks within the bounding box +# content: +# application/json: +# schema: +# type: object +# properties: +# data: +# type: array +# items: +# $ref: '#/components/schemas/org.maproulette.framework.model.Task' +# total: +# type: integer +# description: Total number of tasks matching the criteria +# page: +# type: integer +# description: Current page number +# limit: +# type: integer +# description: Number of items per page +# '400': +# description: Invalid parameters provided +# '401': +# description: The user is not authorized to make this request +# parameters: +# - name: bounds +# in: query +# description: Comma-separated bounding box coordinates in format "left,bottom,right,top" (e.g., "-122.5,37.7,-122.3,37.9"). If not provided, returns tasks without geographic filtering. +# required: false +# schema: +# type: string +# - name: challengeIds +# in: query +# description: Comma-separated list of challenge IDs to filter tasks by (e.g., "123,456,789") +# required: false +# schema: +# type: string +# - name: limit +# in: query +# description: Maximum number of tasks to return per page +# required: false +# schema: +# type: integer +# default: 10 +# - name: page +# in: query +# description: Page number for pagination (0-indexed) +# required: false +# schema: +# type: integer +# default: 0 +### +GET /tasks/bounds @org.maproulette.framework.controller.TaskController.getChallengeTasksInBounds(bounds:String ?= "", challengeIds:String ?= "", limit:Int ?= 10, page:Int ?= 0) +### +# tags: [ Task ] +# operationId: task_retrieves_task_clusters_use_put_method # summary: Retrieves task clusters. USE PUT METHOD # deprecated: true # responses: @@ -916,6 +1221,7 @@ GET /tasks/box/:left/:bottom/:right/:top @org.maproulette.framework.c # description: Success ### # tags: [ Task ] +# operationId: task_request_task_unlock # summary: Request task unlock # description: Sends a notification to the user who has locked a task requesting them to unlock it # responses: @@ -939,6 +1245,7 @@ PUT /task/:taskId/unlock/request @org.maproulette.framework.c GET /taskCluster @org.maproulette.framework.controller.TaskController.getTaskClusters(points:Int ?= 100) ### # tags: [ Task ] +# operationId: task_locks_a_bundle_of_tasks # summary: Locks a bundle of tasks # description: Attempts to lock a set of tasks. If successful, returns the tasks that were locked. If not successful, returns the tasks that were not locked. # responses: @@ -959,6 +1266,7 @@ GET /taskCluster @org.maproulette.framework.c POST /task/bundle/lock @org.maproulette.controllers.api.TaskController.lockTaskBundle(taskIds:List[Long]) ### # tags: [ Task ] +# operationId: task_unlocks_a_bundle_of_tasks # summary: Unlocks a bundle of tasks # description: Unlocks the specified tasks in the bundle. # responses: diff --git a/conf/v2_route/team.api b/conf/v2_route/team.api index c0f51f6f7..e1af79f17 100644 --- a/conf/v2_route/team.api +++ b/conf/v2_route/team.api @@ -1,5 +1,6 @@ ### # tags: [ Team ] +# operationId: team_create_a_new_team # summary: Create a new team # description: Creates a new team # responses: @@ -20,6 +21,7 @@ POST /team @org.maproulette.framework.controller.TeamController.createTeam() ### # tags: [ Team ] +# operationId: team_update_a_team # summary: Update a team # description: Updates the team info (name, description, avatar URL) # responses: @@ -44,6 +46,7 @@ POST /team @org.maproulette.framework.c PUT /team/:id @org.maproulette.framework.controller.TeamController.updateTeam(id: Long) ### # tags: [ Team ] +# operationId: team_retrieves_a_team # summary: Retrieves a team # description: Retrieves a team based on a specific ID. # responses: @@ -63,6 +66,7 @@ PUT /team/:id @org.maproulette.framework.c GET /team/:id @org.maproulette.framework.controller.TeamController.retrieve(id:Long) ### # tags: [ Team ] +# operationId: team_find_teams_by_name # summary: Find teams by name # description: Search for teams by name # responses: @@ -90,6 +94,7 @@ GET /team/:id @org.maproulette.framework.c GET /teams/find @org.maproulette.framework.controller.TeamController.find(name:String, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Team ] +# operationId: team_retrieves_users_who_are_members_of_a_team # summary: Retrieves users who are members of a team # description: Retrieves all the user members of a team # responses: @@ -111,6 +116,7 @@ GET /teams/find @org.maproulette.framework.c GET /team/:id/userMembers @org.maproulette.framework.controller.TeamController.teamUsers(id:Long) ### # tags: [ Team ] +# operationId: team_retrieves_all_team_memberships_for_a_user # summary: Retrieves all team memberships for a user # description: Retrieves all the given user's team memberships # responses: @@ -132,6 +138,7 @@ GET /team/:id/userMembers @org.maproulette.framework.c GET /team/all/user/:userId/memberships @org.maproulette.framework.controller.TeamController.userTeamMemberships(userId:Long) ### # tags: [ Team ] +# operationId: team_invites_a_user_to_join_a_team # summary: Invites a user to join a team # description: Invites a user to join a team with the given role # responses: @@ -157,6 +164,7 @@ GET /team/all/user/:userId/memberships @org.maproulette.framework.c POST /team/:id/user/:userId/invite/:role @org.maproulette.framework.controller.TeamController.inviteUser(id:Long, userId:Long, role:Int) ### # tags: [ Team ] +# operationId: team_accept_an_invitation_to_join_a_team # summary: Accept an invitation to join a team # description: Accepts the logged-in user's invitation to join a team # responses: @@ -176,6 +184,7 @@ POST /team/:id/user/:userId/invite/:role @org.maproulette.framework.c PUT /team/:id/invite/accept @org.maproulette.framework.controller.TeamController.acceptInvite(id:Long) ### # tags: [ Team ] +# operationId: team_decline_an_invitation_to_join_a_team # summary: Decline an invitation to join a team # description: Decline the logged-in user's invitation to join a team # responses: @@ -191,6 +200,7 @@ PUT /team/:id/invite/accept @org.maproulette.framework.c DELETE /team/:id/invite @org.maproulette.framework.controller.TeamController.declineInvite(id:Long) ### # tags: [ Team ] +# operationId: team_update_a_team_members_role # summary: Update a team member's role # description: Update a team member's granted role on the team # responses: @@ -216,6 +226,7 @@ DELETE /team/:id/invite @org.maproulette.framework.c PUT /team/:id/user/:userId/role/:role @org.maproulette.framework.controller.TeamController.updateMemberRole(id:Long, userId:Long, role:Int) ### # tags: [ Team ] +# operationId: team_remove_a_member_from_a_team # summary: Remove a member from a team # description: Remove a team member from a team # responses: @@ -234,6 +245,7 @@ PUT /team/:id/user/:userId/role/:role @org.maproulette.framework.c DELETE /team/:id/user/:userId/ @org.maproulette.framework.controller.TeamController.removeTeamMember(id:Long, userId:Long) ### # tags: [ Team ] +# operationId: team_grant_role_to_team_on_project # summary: Grant role to team on project # description: Grant a team an Admin, Write or Read role on the project # responses: @@ -255,6 +267,7 @@ DELETE /team/:id/user/:userId/ @org.maproulette.framework.c POST /team/:teamId/project/:projectId/:role @org.maproulette.framework.controller.TeamController.addTeamToProject(teamId:Long, projectId:Long, role:Int) ### # tags: [ Team ] +# operationId: team_set_granted_role_of_team_on_project # summary: Set granted role of team on project # description: Grant a team an Admin, Write or Read role on the project, clearing any prior roles # responses: @@ -276,6 +289,7 @@ POST /team/:teamId/project/:projectId/:role @org.maproulette.framework.cont PUT /team/:teamId/project/:projectId/:role @org.maproulette.framework.controller.TeamController.setTeamProjectRole(teamId:Long, projectId:Long, role:Int) ### # tags: [ Team ] +# operationId: team_remove_granted_roles_on_project_from_team # summary: Remove granted roles on project from team # description: Remove roles on a project from a team # responses: @@ -294,6 +308,7 @@ PUT /team/:teamId/project/:projectId/:role @org.maproulette.framework.cont DELETE /team/:teamId/project/:projectId @org.maproulette.framework.controller.TeamController.removeTeamFromProject(teamId:Long, projectId:Long) ### # tags: [ Team ] +# operationId: team_get_teams_granted_a_role_on_a_project # summary: Get teams granted a role on a project # description: Get teams granted an Admin, Write or Read role on a project # responses: @@ -315,6 +330,7 @@ DELETE /team/:teamId/project/:projectId @org.maproulette.framework.cont GET /teams/projectManagers/:projectId @org.maproulette.framework.controller.TeamController.getTeamsManagingProject(projectId:Long) ### # tags: [ Team ] +# operationId: team_delete_a_team # summary: Delete a team # description: Deletes a team with ID # responses: diff --git a/conf/v2_route/user.api b/conf/v2_route/user.api index 71a31d4a6..14d0cb40f 100644 --- a/conf/v2_route/user.api +++ b/conf/v2_route/user.api @@ -1,5 +1,6 @@ ### # tags: [ User ] +# operationId: user_retrieves_current_user # summary: Retrieves current user # description: Retrieves the current logged-in user's JSON # responses: @@ -15,6 +16,7 @@ GET /user/whoami @org.maproulette.framework.controller.UserController.whoami() ### # tags: [ User ] +# operationId: user_retrieves_users_json_information # summary: Retrieves Users Json information # description: Retrieves User Json based on the supplied ID # responses: @@ -36,6 +38,7 @@ GET /user/whoami @org.maproulette.framework.co GET /user/:userId @org.maproulette.framework.controller.UserController.getUser(userId:Long) ### # tags: [ User ] +# operationId: user_retrieves_user_by_osm_id # summary: Retrieves Users Json information # description: Retrieves User Json based on the supplied OSM username # responses: @@ -57,6 +60,7 @@ GET /user/:userId @org.maproulette.framework.c GET /osmuser/:username @org.maproulette.framework.controller.UserController.getUserByOSMUsername(username:String) ### # tags: [ User ] +# operationId: user_deletes_a_user_from_the_database # summary: Deletes a user from the database # description: This will delete a user completely from the database. It can also optionally anonymize the users data from actions taken in MapRoulette, like change in status for tasks, comments on tasks and answers to survey questions # responses: @@ -82,6 +86,7 @@ GET /osmuser/:username @org.maproulette.framework.c DELETE /user/:osmId @org.maproulette.framework.controller.UserController.deleteUser(osmId:Long, anonymize:Boolean ?= false) ### # tags: [ User ] +# operationId: user_retrieves_users_public_json_information # summary: Retrieves Users public Json information # description: Retrieves a JSON object that represents the user's public information that anyone can retrieve. This is a limited set of information that only includes certain fields. # responses: @@ -101,6 +106,7 @@ DELETE /user/:osmId @org.maproulette.framework.c GET /user/:userId/public @org.maproulette.framework.controller.UserController.getPublicUser(userId:Long) ### # tags: [ User ] +# operationId: user_retrieves_public_user_by_name # summary: Retrieves Users public Json information # description: Retrieves User Json based on the supplied OSM username # responses: @@ -120,6 +126,7 @@ GET /user/:userId/public @org.maproulette.fram GET /osmuser/:username/public @org.maproulette.framework.controller.UserController.getPublicUserByOSMUsername(username:String) ### # tags: [ User ] +# operationId: user_deletes_user_from_project # summary: Deletes a user from the database # description: This will delete a user completely from the database. It can also optionally anonymize the users data from actions taken in MapRoulette, like change in status for tasks, comments on tasks and answers to survey questions # responses: @@ -142,6 +149,7 @@ GET /osmuser/:username/public @org.maproulette.fram DELETE /user/:osmId @org.maproulette.framework.controller.UserController.deleteUser(osmId:Long, anonymize:Boolean ?= false) ### # tags: [ User ] +# operationId: user_generates_an_api_key_for_a_specified_user # summary: Generates an API_KEY for a specified user # description: This API will generate or regenerate the API_KEY for a specified user # responses: @@ -163,6 +171,7 @@ DELETE /user/:osmId @org.maproulette.framework.c PUT /user/:userId/apikey @org.maproulette.framework.controller.UserController.generateAPIKey(userId:Long) ### # tags: [ User ] +# operationId: user_search_for_users_by_osm_username # summary: Search for users by OSM username # description: Retrieves list of matching users # responses: @@ -194,6 +203,7 @@ GET /users/find/:username @org.maproulette.framewor GET /users/find @org.maproulette.framework.controller.UserController.searchUserByOSMUsername(username:String ?= "", limit:Int ?= 10) ### # tags: [ User ] +# operationId: user_get_a_list_of_users # summary: Get a list of users # description: Retrieves list of matching users # responses: @@ -216,6 +226,40 @@ GET /users/find @org.maproulette.framewor GET /users @org.maproulette.framework.controller.UserController.extendedFind(limit:Int ?= 10, page:Int ?= 0, sort:String ?= "") ### # tags: [ User ] +# operationId: superadmin_get_all_users +# summary: Super Admin - Get all users with pagination +# description: Retrieves a paginated list of all users in the system. Only accessible to super admins. +# responses: +# '200': +# description: The retrieved users +# content: +# application/json: +# schema: +# type: array +# items: +# $ref: '#/components/schemas/org.maproulette.framework.model.User' +# '401': +# description: The user is not authorized to make this request +# '403': +# description: Only super admins can access this endpoint +# parameters: +# - name: limit +# in: query +# description: Limit the number of results returned in the response. Default value is 50. +# schema: +# type: integer +# default: 50 +# - name: page +# in: query +# description: Used in conjunction with the limit parameter to page through X number of responses. Default value is 0. +# schema: +# type: integer +# default: 0 +### +GET /super-admin/users @org.maproulette.framework.controller.UserController.getAllUsersForSuperAdmin(limit:Int ?= 50, page:Int ?= 0) +### +# tags: [ User ] +# operationId: user_retrieves_users_saved_challenged # summary: Retrieves Users Saved Challenged # description: Retrieves that list of challenges that has been saved by the User # responses: @@ -243,6 +287,7 @@ GET /users @org.maproulette.framewor GET /user/:userId/saved @org.maproulette.framework.controller.UserController.getSavedChallenges(userId:Long, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ User ] +# operationId: user_saves_challenge_for_a_user # summary: Saves Challenge for a User # description: Saves a Challenge to a user account # responses: @@ -267,6 +312,7 @@ GET /user/:userId/saved @org.maproulette.framework.c POST /user/:userId/save/:challengeId @org.maproulette.framework.controller.UserController.saveChallenge(userId:Long, challengeId:Long) ### # tags: [ User ] +# operationId: user_unsaves_challenge_for_a_user # summary: Unsaves Challenge for a User # description: Unsaves a Challenge to a user account # responses: @@ -291,6 +337,7 @@ POST /user/:userId/save/:challengeId @org.maproulette.framework.c DELETE /user/:userId/unsave/:challengeId @org.maproulette.framework.controller.UserController.unsaveChallenge(userId:Long, challengeId:Long) ### # tags: [ User ] +# operationId: user_retrieves_users_saved_tasks # summary: Retrieves Users Saved Tasks # description: Retrieves that list of tasks that has been saved by the User # responses: @@ -321,6 +368,7 @@ DELETE /user/:userId/unsave/:challengeId @org.maproulette.framework.c GET /user/:userId/savedTasks @org.maproulette.framework.controller.UserController.getSavedTasks(userId:Long, challengeIds:String ?= "", limit:Int ?= 10, page:Int ?= 0) ### # tags: [ User ] +# operationId: user_retrieves_users_locked_tasks # summary: Retrieves Users Locked Tasks # description: Retrieves a list of all the tasks the user with the matching id has locked # responses: @@ -351,6 +399,7 @@ GET /user/:userId/savedTasks @org.maproulette.framework.c GET /user/:userId/lockedTasks @org.maproulette.framework.controller.UserController.getLockedTasks(userId:Long, limit:Int ?= 50) ### # tags: [ User ] +# operationId: user_saves_a_task_for_a_user # summary: Saves a Task for a User # description: Saves a Task to a user account # responses: @@ -375,6 +424,7 @@ GET /user/:userId/lockedTasks @org.maproulette.framework. POST /user/:userId/saveTask/:taskId @org.maproulette.framework.controller.UserController.saveTask(userId:Long, taskId:Long) ### # tags: [ User ] +# operationId: user_unsaves_task_for_a_user # summary: Unsaves Task for a User # description: Unsaves a Task from a user account # responses: @@ -399,6 +449,7 @@ POST /user/:userId/saveTask/:taskId @org.maproulette.framework.c DELETE /user/:userId/unsaveTask/:taskId @org.maproulette.framework.controller.UserController.unsaveTask(userId:Long, taskId:Long) ### # tags: [ User ] +# operationId: user_updates_usersettings # summary: Updates UserSettings # description: Updates the user settings for a specified user # responses: @@ -429,6 +480,7 @@ DELETE /user/:userId/unsaveTask/:taskId @org.maproulette.framework.c PUT /user/:userId @org.maproulette.framework.controller.UserController.updateUser(userId:Long) ### # tags: [ User ] +# operationId: user_refresh_user_profile # summary: Refresh User Profile # description: Refreshes the user profile from OSM # responses: @@ -444,6 +496,7 @@ PUT /user/:userId @org.maproulette.framework.c PUT /user/:userId/refresh @org.maproulette.framework.controller.UserController.refreshProfile(userId:Long) ### # tags: [ User ] +# operationId: user_gets_a_list_of_users_managing_project # summary: Gets a list of users managing project # description: Gets list of users managing project along with their roles (1 - Admin, 2 - Write, 3 - Read) # responses: @@ -474,6 +527,7 @@ PUT /user/:userId/refresh @org.maproulette.framework. GET /user/project/:projectId @org.maproulette.framework.controller.UserController.getUsersManagingProject(projectId:Long, osmIds:String ?= "", includeTeams:Boolean ?= false) ### # tags: [ User ] +# operationId: user_grant_role_to_user_on_project # summary: Grant role to user on project # description: Grants a user an Admin, Write or Read role on the project # responses: @@ -502,6 +556,7 @@ GET /user/project/:projectId @org.maproulette.framework. POST /user/:userId/project/:projectId/:role @org.maproulette.framework.controller.UserController.addUserToProject(userId:Long, projectId:Long, role:Int, isOSMUserId:Boolean ?= false) ### # tags: [ User ] +# operationId: user_set_project_role_for_user_removing_any_prior_roles # summary: Set project role for user, removing any prior roles on the project # description: Sets a user's role on the project to Admin, Write or Read. This will also remove any other roles on the project from the user. # responses: @@ -529,6 +584,7 @@ POST /user/:userId/project/:projectId/:role @org.maproulette.framework.cont PUT /user/:userId/project/:projectId/:role @org.maproulette.framework.controller.UserController.setUserProjectRole(userId:Long, projectId:Long, role:Int, isOSMUserId:Boolean ?= false) ### # tags: [ User ] +# operationId: user_grant_role_on_project_to_a_list_of_users # summary: Grant role on project to a list of users # description: Grants Admin, Write, or Read role on project to list of users # responses: @@ -564,6 +620,7 @@ PUT /user/:userId/project/:projectId/:role @org.maproulette.framework.contr PUT /user/project/:projectId/:role @org.maproulette.framework.controller.UserController.addUsersToProject(projectId:Long, role:Int, isOSMUserId:Boolean ?= false) ### # tags: [ User ] +# operationId: user_remove_granted_role_on_project_from_user # summary: Remove granted role on project from user # description: Removes Admin, Write, or Read role on a project from a user # responses: @@ -592,6 +649,7 @@ PUT /user/project/:projectId/:role @org.maproulette.framework.contro DELETE /user/:userId/project/:projectId/:role @org.maproulette.framework.controller.UserController.removeUserFromProject(userId:Long, projectId:Long, role:Int, isOSMUserId:Boolean ?= false) ### # tags: [ User ] +# operationId: user_removes_granted_role_on_project_from_a_list_of_use # summary: Removes granted role on project from a list of users # description: Removes Admin, Write, or Read role on project from a list of users # responses: @@ -628,6 +686,7 @@ DELETE /user/:userId/project/:projectId/:role @org.maproulette.framework.contr DELETE /user/project/:projectId/:role @org.maproulette.framework.controller.UserController.removeUsersFromProject(projectId:Long, role:Int, isOSMUserId:Boolean ?= false) ### # tags: [ User ] +# operationId: user_promote_a_standard_user_to_a_super_user # summary: Promote a standard user to a super user # description: Promote a standard user, a 'grantee', to a super user role; the requesting user is called a 'grantor'. # This will add the superuser role to the grantee user, allowing the grantee to perform super user actions. @@ -649,6 +708,7 @@ DELETE /user/project/:projectId/:role @org.maproulette.framework.contro PUT /superuser/:userId @org.maproulette.framework.controller.UserController.promoteUserToSuperUser(userId:Long) ### # tags: [ User ] +# operationId: user_remove_the_superuser_role_from_a_super_user # summary: Remove the superuser role from a super user # description: Demote a super user, a 'grantee', back to a standard user; the requesting user is called a 'grantor'. # This will remove the superuser role from the grantee. @@ -670,6 +730,7 @@ PUT /superuser/:userId @org.maproulette.framework.control DELETE /superuser/:userId @org.maproulette.framework.controller.UserController.demoteSuperUserToUser(userId:Long) ### # tags: [ User ] +# operationId: user_get_all_current_superusers # summary: Get all current superusers # description: Return a list of MapRoulette user ids who are superusers. The requesting user must be a super user. # responses: diff --git a/conf/v2_route/virtualchallenge.api b/conf/v2_route/virtualchallenge.api index a969b7d45..cc73fb456 100644 --- a/conf/v2_route/virtualchallenge.api +++ b/conf/v2_route/virtualchallenge.api @@ -1,5 +1,6 @@ ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_create_a_virtual_challenge # summary: Create a Virtual Challenge # description: Will create a new Virtual Challenge from the supplied JSON in the body. # When creating the Virtual Challenge, leave the ID field out of the body json, @@ -28,6 +29,7 @@ POST /virtualchallenge @org.maproulette.controllers.api.VirtualChallengeController.create ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_updates_a_virtual_challenge # summary: Updates a Virtual Challenge # description: Will update an already existing Virtual Challenge from the supplied JSON in the body. # responses: @@ -58,6 +60,7 @@ POST /virtualchallenge @org.maproulette.controllers PUT /virtualchallenge/:id @org.maproulette.controllers.api.VirtualChallengeController.update(id:Long) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_retrieves_an_already_existing_virtual_challenge # summary: Retrieves an already existing Virtual Challenge # description: Retrieves an already existing Virtual Challenge based on the supplied ID in the URL. # responses: @@ -76,6 +79,7 @@ PUT /virtualchallenge/:id @org.maproulette.controllers GET /virtualchallenge/:id @org.maproulette.controllers.api.VirtualChallengeController.read(id:Long) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_retrieves_an_already_existing_virtual_challenge_by_id # summary: Retrieves an already existing Virtual Challenge # description: Retrieves an already existing Virtual Challenge based on the name of the Virtual Challenge rather than an ID # responses: @@ -98,6 +102,7 @@ GET /virtualchallenge/:id @org.maproulette.cont GET /virtualchallengebyname/:name @org.maproulette.controllers.api.VirtualChallengeController.readByName(id:Long ?= -1, name:String) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_deletes_an_existing_virtual_challenge # summary: Deletes an existing Virtual Challenge # description: Deletes an existing Virtual Challenge based on the supplied ID. This will delete all associated Tasks of the Virtual Challenge. # responses: @@ -122,6 +127,7 @@ GET /virtualchallengebyname/:name @org.maproulette.control DELETE /virtualchallenge/:id @org.maproulette.controllers.api.VirtualChallengeController.delete(id:Long, immediate:Boolean ?= false) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_list_all_the_virtual_challenge # summary: List all the Virtual Challenge. # description: Lists all the Virtual Challenges in the system # responses: @@ -144,6 +150,7 @@ DELETE /virtualchallenge/:id @org.maproulette.control GET /virtualchallenges @org.maproulette.controllers.api.VirtualChallengeController.list(limit:Int ?= 10, page:Int ?= 0, onlyEnabled:Boolean ?= false) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_list_all_the_virtual_challenges_tasks # summary: List all the Virtual Challenges Tasks. # description: Lists all the Tasks that are children of the supplied Virtual Challenge. # responses: @@ -169,6 +176,7 @@ GET /virtualchallenges @org.maproulette.control GET /virtualchallenge/:id/tasks @org.maproulette.controllers.api.VirtualChallengeController.listTasks(id:Long, limit:Int ?= 10, page:Int ?= 0) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_retrieves_random_task # summary: Retrieves random Task # description: Retrieves a random Task based on the search criteria and contained within the current Virtual Challenge # responses: @@ -189,6 +197,7 @@ GET /virtualchallenge/:id/tasks @org.maproulette.control GET /virtualchallenge/:cid/task @org.maproulette.controllers.api.VirtualChallengeController.getRandomTask(cid:Long, proximity:Long ?= -1) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_retrieves_next_task # summary: Retrieves next Task # description: Retrieves the next sequential Task based on the task ordering within the Virtual Challenge. If it is currently on the last task it will response with the first task in the Virtual Challenge. # responses: @@ -209,6 +218,7 @@ GET /virtualchallenge/:cid/task @org.maproulette.controllers GET /virtualchallenge/:cid/nextTask/:id @org.maproulette.controllers.api.VirtualChallengeController.getSequentialNextTask(cid:Long, id:Long) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_retrieves_previous_task # summary: Retrieves previous Task # description: Retrieves the previous sequential Task based on the task ordering within the Virtual Challenge. If it is currently on the first task it will response with the last task in the Virtual Challenge. # responses: @@ -229,6 +239,7 @@ GET /virtualchallenge/:cid/nextTask/:id @org.maproulette.controllers GET /virtualchallenge/:cid/previousTask/:id @org.maproulette.controllers.api.VirtualChallengeController.getSequentialPreviousTask(cid:Long, id:Long) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_retrieves_virtual_challenge_geojson # summary: Retrieves Virtual Challenge GeoJSON # description: Retrieves the GeoJSON for the Virtual Challenge that represents all the associated Tasks of the Virtual Challenge. # WARNING* This API query can be quite slow due to retrieving all the points that is grouped in various different challenges @@ -248,6 +259,7 @@ GET /virtualchallenge/:cid/previousTask/:id @org.maproulette.controllers GET /virtualchallenge/view/:id @org.maproulette.controllers.api.VirtualChallengeController.getVirtualChallengeGeoJSON(id:Long, filter:String ?= "") ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_retrieves_clustered_task_points # summary: Retrieves clustered Task points # description: Retrieves all the Tasks for a specific Virtual Challenge as clustered points to potentially display on a map. # responses: @@ -270,6 +282,7 @@ GET /virtualchallenge/view/:id @org.maproulette.controllers GET /virtualchallenge/clustered/:id @org.maproulette.controllers.api.VirtualChallengeController.getClusteredPoints(id:Long, filter:String ?= "") ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_retrieves_nearby_tasks_in_virtual_challenge # summary: Retrieves nearby Tasks in Virtual Challenge # description: Retrieves tasks geographically closest to the specified task within the same Virtual Challenge # responses: @@ -292,6 +305,7 @@ GET /virtualchallenge/clustered/:id @org.maproulette.controllers GET /virtualchallenge/:id/tasksNearby/:proximityId @org.maproulette.controllers.api.VirtualChallengeController.getNearbyTasks(id:Long, proximityId:Long, limit:Int ?= 5) ### # tags: [ Virtual Challenge ] +# operationId: virtual_challenge_retrieves_available_tasks_in_virtual_challenge_wit # summary: Retrieves available Tasks in Virtual Challenge within a bounding box # description: Retrieves available tasks within a bounding box # responses: diff --git a/conf/v2_route/virtualproject.api b/conf/v2_route/virtualproject.api index 54e5a28f6..788f739bb 100644 --- a/conf/v2_route/virtualproject.api +++ b/conf/v2_route/virtualproject.api @@ -1,5 +1,6 @@ ### # tags: [ Virtual Project ] +# operationId: virtual_project_add_challenge_to_a_virtual_project # summary: Add Challenge to a virtual Project # description: Will add a challenge into a virtual project # responses: @@ -18,6 +19,7 @@ POST /project/:projectId/challenge/:id/add @org.maproulette.framework.controller.VirtualProjectController.addChallenge(projectId:Long, id:Long) ### # tags: [ Virtual Project ] +# operationId: virtual_project_remove_challenge_from_a_virtual_project # summary: Remove Challenge from a virtual Project # description: Will remove a challenge from a virtual project # responses: diff --git a/project/plugins.sbt b/project/plugins.sbt index d85fb804f..661e77a2a 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -5,21 +5,21 @@ addDependencyTreePlugin // https://github.com/playframework/playframework/releases addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.9.11") -addSbtPlugin("com.typesafe.sbt" % "sbt-gzip" % "1.0.2") +addSbtPlugin("com.github.sbt" % "sbt-gzip" % "2.0.0") addSbtPlugin("com.beautiful-scala" %% "sbt-scalastyle" % "1.5.1") -addSbtPlugin("io.github.play-swagger" % "sbt-play-swagger" % "1.6.2") +addSbtPlugin("io.github.play-swagger" % "sbt-play-swagger" % "1.7.3") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") -addSbtPlugin("com.github.sbt" % "sbt-jacoco" % "3.4.0") +addSbtPlugin("com.github.sbt" % "sbt-jacoco" % "3.5.0") addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.6") -addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.13.1") -addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.1") +addSbtPlugin("com.github.sbt" % "sbt-git" % "2.1.0") // https://github.com/sbt/sbt/issues/6997 ThisBuild / libraryDependencySchemes ++= Seq( diff --git a/test/org/maproulette/framework/repository/TaskRepositorySpec.scala b/test/org/maproulette/framework/repository/TaskRepositorySpec.scala index 21bf9d3d9..397653f27 100644 --- a/test/org/maproulette/framework/repository/TaskRepositorySpec.scala +++ b/test/org/maproulette/framework/repository/TaskRepositorySpec.scala @@ -10,6 +10,7 @@ import java.util.UUID import org.maproulette.framework.model.{User, Task} import org.maproulette.framework.util.{TaskTag, FrameworkHelper} import play.api.Application +import play.api.libs.json.{JsObject, Json} /** * @author nrotstan @@ -45,7 +46,10 @@ class TaskRepositorySpec(implicit val application: Application) extends Framewor null, null, this.defaultChallenge.id, - geometries = - """{"features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[-60.811801,-32.9199812],[-60.8117804,-32.9199856],[-60.8117816,-32.9199896],[-60.8117873,-32.919984]]},"properties":{"osm_id":"OSM_W_378169283_000000_000","pbfHistory":["20200110-043000"]}}], "attachments": [{"id": "74bc872f-8448-45ff-b8f2-66517a35b41e", "kind": "referenceLayer", "type": "geojson", "name": "geojson boundary", "data": {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-121.0, 48.0],[-120.0, 48.0],[-120.0, 49.0],[-121.0, 49],[-121.0, 48.0]]]}}}]}""" + geometries = Json + .parse( + """{"features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[-60.811801,-32.9199812],[-60.8117804,-32.9199856],[-60.8117816,-32.9199896],[-60.8117873,-32.919984]]},"properties":{"osm_id":"OSM_W_378169283_000000_000","pbfHistory":["20200110-043000"]}}], "attachments": [{"id": "74bc872f-8448-45ff-b8f2-66517a35b41e", "kind": "referenceLayer", "type": "geojson", "name": "geojson boundary", "data": {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-121.0, 48.0],[-120.0, 48.0],[-120.0, 49.0],[-121.0, 49],[-121.0, 48.0]]]}}}]}""" + ) + .as[JsObject] ) } diff --git a/test/org/maproulette/framework/repository/TileAggregateRepositorySpec.scala b/test/org/maproulette/framework/repository/TileAggregateRepositorySpec.scala new file mode 100644 index 000000000..902c38608 --- /dev/null +++ b/test/org/maproulette/framework/repository/TileAggregateRepositorySpec.scala @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2020 MapRoulette contributors (see CONTRIBUTORS.md). + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + +package org.maproulette.framework.repository + +import anorm._ +import org.maproulette.framework.util.{FrameworkHelper, TileAggregateRepoTag} +import play.api.Application +import play.api.db.Database + +/** + * Integration tests for the grid-binned tile pipeline: + * 1. A task mutation fires `mark_dirty_on_task_change_trigger`, enqueueing + * the affected leaf cell in `tile_dirty_cells`. + * 2. `rebuildDirtyCells` drains the queue, recomputing each leaf cell from + * the base tables and rolling the change up to z=0. + * + * The background `TileDirtyListener` is disabled under the test configuration + * so queue state is observable deterministically here. + */ +class TileAggregateRepositorySpec(implicit val application: Application) extends FrameworkHelper { + val repository: TileAggregateRepository = + this.application.injector.instanceOf(classOf[TileAggregateRepository]) + + val db: Database = this.application.injector.instanceOf(classOf[Database]) + + override implicit val projectTestName: String = "TileAggregateRepositorySpecProject" + + "TileAggregateRepository" should { + "drain a queued dirty cell via rebuildDirtyCells" taggedAs TileAggregateRepoTag in { + // Seed a dirty leaf cell at coordinates with no tasks; the drain should + // pop it and correctly leave no tile_cells row behind. + db.withConnection { implicit c => + SQL"DELETE FROM tile_dirty_cells".executeUpdate() + SQL"INSERT INTO tile_dirty_cells (cx, cy) VALUES (1, 1)".executeUpdate() + } + + val processed = repository.rebuildDirtyCells(limit = 1000) + processed must be >= 1 + repository.getDirtyCellCount() mustEqual 0 + } + + "fire the task-change trigger and queue a dirty cell on status update" taggedAs + TileAggregateRepoTag in { + db.withConnection { implicit c => + SQL"DELETE FROM tile_dirty_cells".executeUpdate() + } + + // A raw UPDATE exercises the trigger directly, without setTaskStatus's + // synchronous post-commit drain emptying the queue again. + db.withConnection { implicit c => + SQL"UPDATE tasks SET status = 3 WHERE id = ${defaultTask.id}".executeUpdate() + } + + // The trigger marks the leaf cell covering the task's location. + repository.getDirtyCellCount() must be >= 1 + + val processed = repository.rebuildDirtyCells(limit = 1000) + processed must be >= 1 + repository.getDirtyCellCount() mustEqual 0 + } + } +} diff --git a/test/org/maproulette/framework/util/FrameworkHelper.scala b/test/org/maproulette/framework/util/FrameworkHelper.scala index 09744fca9..91a148ade 100644 --- a/test/org/maproulette/framework/util/FrameworkHelper.scala +++ b/test/org/maproulette/framework/util/FrameworkHelper.scala @@ -16,6 +16,7 @@ import org.scalatest.{BeforeAndAfterAll, Tag} import org.scalatestplus.mockito.MockitoSugar import org.scalatestplus.play.PlaySpec import play.api.Application +import play.api.libs.json.{JsObject, Json} import org.maproulette.data.SnapshotManager @@ -116,8 +117,11 @@ trait FrameworkHelper extends PlaySpec with BeforeAndAfterAll with MockitoSugar null, null, parentId, - geometries = - "{\"features\":[{\"type\":\"Feature\",\"geometry\":{\"type\":\"LineString\",\"coordinates\":[[-60.811801,-32.9199812],[-60.8117804,-32.9199856],[-60.8117816,-32.9199896],[-60.8117873,-32.919984]]},\"properties\":{\"osm_id\":\"OSM_W_378169283_000000_000\",\"pbfHistory\":[\"20200110-043000\"]}}]}", + geometries = Json + .parse( + "{\"features\":[{\"type\":\"Feature\",\"geometry\":{\"type\":\"LineString\",\"coordinates\":[[-60.811801,-32.9199812],[-60.8117804,-32.9199856],[-60.8117816,-32.9199896],[-60.8117873,-32.919984]]},\"properties\":{\"osm_id\":\"OSM_W_378169283_000000_000\",\"pbfHistory\":[\"20200110-043000\"]}}]}" + ) + .as[JsObject], status = Some(0) ) } @@ -184,3 +188,4 @@ object TeamTag extends Tag("teamtag") object NotificationTag extends Tag("notificationtag") object LeaderboardTag extends Tag("leaderboardtag") object LeaderboardRepoTag extends Tag("leaderboardrepotag") +object TileAggregateRepoTag extends Tag("tileaggregaterepotag") diff --git a/test/org/maproulette/models/ChallengeSpec.scala b/test/org/maproulette/models/ChallengeSpec.scala index d20eaf696..5d9776e10 100644 --- a/test/org/maproulette/models/ChallengeSpec.scala +++ b/test/org/maproulette/models/ChallengeSpec.scala @@ -8,6 +8,7 @@ package org.maproulette.models import org.maproulette.framework.model.{PriorityRule, Task} import org.scalatestplus.play.PlaySpec import org.joda.time.DateTime +import play.api.libs.json.{JsObject, Json} /** * @author cuthbertm @@ -58,9 +59,9 @@ class ChallengeSpec() extends PlaySpec { } "bounds type should operate correctly" in { - val location = Some("{\"type\":\"Point\",\"coordinates\":[-120,50]}") + val location = Json.parse("{\"type\":\"Point\",\"coordinates\":[-120,50]}").asOpt[JsObject] - val task = Task(1, "Task1", DateTime.now(), DateTime.now(), 1, None, location, "") + val task = Task(1, "Task1", DateTime.now(), DateTime.now(), 1, None, location, Json.obj()) // format like: bounds = "MinX,MinY,MaxX,MaxY" PriorityRule("contains", "location", "0,0,1,1", "bounds") diff --git a/test/org/maproulette/utils/TestSpec.scala b/test/org/maproulette/utils/TestSpec.scala index 63cf08c20..fc94d8bec 100644 --- a/test/org/maproulette/utils/TestSpec.scala +++ b/test/org/maproulette/utils/TestSpec.scala @@ -21,6 +21,7 @@ import org.scalatestplus.mockito.MockitoSugar import org.scalatestplus.play.PlaySpec import play.api.Configuration import play.api.db.Databases +import play.api.libs.json.Json /** * @author mcuthbert @@ -90,7 +91,7 @@ trait TestSpec extends PlaySpec with MockitoSugar { ) //tasks - val task1 = Task(1, "Task1", DateTime.now(), DateTime.now(), 1, None, None, "") + val task1 = Task(1, "Task1", DateTime.now(), DateTime.now(), 1, None, None, Json.obj()) val taskDAL = mock[TaskDAL] val challengeDAL = mock[ChallengeDAL] val virtualChallengeDAL = mock[VirtualChallengeDAL] @@ -146,6 +147,8 @@ trait TestSpec extends PlaySpec with MockitoSugar { val notificationService = mock[NotificationService] val leaderboardService = mock[LeaderboardService] val taskHistoryService = mock[TaskHistoryService] + val tileAggregateService = mock[TileAggregateService] + // format: off val serviceManager = new ServiceManager( Providers.of[ProjectService](projectService), Providers.of[GrantService](grantService), @@ -169,8 +172,10 @@ trait TestSpec extends PlaySpec with MockitoSugar { Providers.of[TeamService](teamService), Providers.of[NotificationService](notificationService), Providers.of[LeaderboardService](leaderboardService), - Providers.of[TaskHistoryService](taskHistoryService) + Providers.of[TaskHistoryService](taskHistoryService), + Providers.of[TileAggregateService](tileAggregateService) ) + // format: on val permission = new Permission(Providers.of[DALManager](dalManager), serviceManager, new Config()) var writeUser = User( From 276cfec20517ab1e99c5f237418bf8559a9ea87c Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Wed, 3 Jun 2026 13:53:54 -0300 Subject: [PATCH 06/10] remove duplicate previewTaskPriorities function --- .../maproulette/models/dal/ChallengeDAL.scala | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index 93e6a848d..ce6bb7da0 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -1400,48 +1400,6 @@ class ChallengeDAL @Inject() ( } } - /** - * Dry-run `updateTaskPriorities`: computes, but does NOT persist, the priority - * that every task in the challenge would receive under the supplied draft - * priority config. Used by the editor preview so the UI can show tier - * membership that is byte-for-byte consistent with what a subsequent save - * would write — including rule-based matches, which the frontend can't - * evaluate because it doesn't ship per-task OSM tags. - */ - def previewTaskPriorities( - user: User, - draft: ChallengePriority - )(implicit id: Long, c: Option[Connection] = None): Map[Long, Int] = { - this.permission.hasWriteAccess(ChallengeType(), user) - this.withMRConnection { implicit c => - val persisted = this._retrieveById(caching = false) match { - case Some(c) => c - case None => - throw new NotFoundException( - s"Could not preview priorities — no challenge with id $id found." - ) - } - // Splice the draft priority config onto a copy of the persisted challenge - // so `task.getTaskPriority` sees the user's in-progress rules/bounds - // while still reading tasks from the live DB. - val draftChallenge = persisted.copy(priority = draft) - val result = scala.collection.mutable.LongMap[Int]() - var pointer = 0 - var currentTasks: List[Task] = List.empty - do { - currentTasks = listChildren(DEFAULT_NUM_CHILDREN_LIST, pointer) - currentTasks.foreach { task => - val p = - try task.getTaskPriority(draftChallenge) - catch { case _: Exception => task.priority } - result.put(task.id, p) - } - pointer += 1 - } while (currentTasks.size >= DEFAULT_NUM_CHILDREN_LIST) - result.toMap - } - } - /** * Lists the children of the parent, override the base functionality and includes the geojson * as part of the query so that it doesn't have to fetch it each and every time. From ca7856924078a248393b5b234a9352a136bb0981 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Wed, 3 Jun 2026 13:59:40 -0300 Subject: [PATCH 07/10] remove unused recompute logic. --- .../controllers/api/ChallengeController.scala | 9 +---- .../maproulette/models/dal/ChallengeDAL.scala | 37 ------------------- 2 files changed, 2 insertions(+), 44 deletions(-) diff --git a/app/org/maproulette/controllers/api/ChallengeController.scala b/app/org/maproulette/controllers/api/ChallengeController.scala index b364a70b6..d13becac3 100644 --- a/app/org/maproulette/controllers/api/ChallengeController.scala +++ b/app/org/maproulette/controllers/api/ChallengeController.scala @@ -1316,13 +1316,8 @@ class ChallengeController @Inject() ( * @return A Json representation of the object */ override def inject(obj: Challenge)(implicit request: Request[Any]): JsValue = { - val tags = this.tagService.listByChallenge(obj.id) - val withTags = Utils.insertIntoJson(Json.toJson(obj), Tag.TABLE, Json.toJson(tags.map(_.name))) - Utils.insertIntoJson( - withTags, - "isRecomputingPriorities", - this.dal.isRecomputingPriorities(obj.id) - ) + val tags = this.tagService.listByChallenge(obj.id) + Utils.insertIntoJson(Json.toJson(obj), Tag.TABLE, Json.toJson(tags.map(_.name))) } /** diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index ce6bb7da0..f4daec0ba 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -57,40 +57,6 @@ class ChallengeDAL @Inject() ( import scala.concurrent.ExecutionContext.Implicits.global - // Process-local bookkeeping for the UI's recompute indicator. The visibility - // window keeps fast recomputes on screen long enough for the frontend's 3s - // poll to catch them; the inFlight counter keeps it on across overlapping - // saves. - private case class RecomputeState( - inFlight: java.util.concurrent.atomic.AtomicInteger, - visibleUntil: java.util.concurrent.atomic.AtomicLong - ) - private val recomputeStates = scala.collection.concurrent.TrieMap.empty[Long, RecomputeState] - private val MIN_INDICATOR_MS = 3000L - - private def recomputeStateFor(id: Long): RecomputeState = - recomputeStates.getOrElseUpdate( - id, - RecomputeState( - new java.util.concurrent.atomic.AtomicInteger(0), - new java.util.concurrent.atomic.AtomicLong(0L) - ) - ) - - def isRecomputingPriorities(id: Long): Boolean = - recomputeStates.get(id).exists { s => - s.inFlight.get() > 0 || s.visibleUntil.get() > System.currentTimeMillis() - } - - private def beginRecompute(id: Long): Unit = - recomputeStateFor(id).inFlight.incrementAndGet() - - private def endRecompute(id: Long): Unit = - recomputeStates.get(id).foreach { s => - s.inFlight.decrementAndGet() - s.visibleUntil.set(System.currentTimeMillis() + MIN_INDICATOR_MS) - } - // The manager for the challenge cache override val cacheManager = new CacheManager[Long, Challenge](config, Config.CACHE_ID_CHALLENGES) // The name of the challenge table @@ -1073,7 +1039,6 @@ class ChallengeDAL @Inject() ( } } if (updatedPriorityRules) { - beginRecompute(id) Future { try updateTaskPriorities(user, overrideValidation = true) catch { @@ -1082,8 +1047,6 @@ class ChallengeDAL @Inject() ( s"updateTaskPriorities failed for challenge $id: ${t.getClass.getName}: ${t.getMessage}", t ) - } finally { - endRecompute(id) } } } From 6ecd80a32e0865cb1ff76226f54554e47d248d34 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Wed, 3 Jun 2026 16:19:30 -0300 Subject: [PATCH 08/10] Refactor task priority computation logic in ChallengeDAL - Updated the `recomputePriorities` method to return the count of rows whose priority actually changed, improving clarity on task updates. - Enhanced error handling by introducing `InvalidException` for untranslatable rules, ensuring better feedback on priority computation failures. - Removed outdated comments and streamlined the logic for counting task priority changes, aligning with the new return structure. - Adjusted the `updateTaskPriorities` method to reflect the changes in priority computation, providing a clearer response for clients. --- .../controllers/api/ChallengeController.scala | 5 - .../maproulette/models/dal/ChallengeDAL.scala | 118 ++++++++++-------- 2 files changed, 65 insertions(+), 58 deletions(-) diff --git a/app/org/maproulette/controllers/api/ChallengeController.scala b/app/org/maproulette/controllers/api/ChallengeController.scala index d13becac3..65dd51e0e 100644 --- a/app/org/maproulette/controllers/api/ChallengeController.scala +++ b/app/org/maproulette/controllers/api/ChallengeController.scala @@ -1959,11 +1959,6 @@ class ChallengeController @Inject() ( this.dal.updateTaskPriorities(user, overrideValidation = true)(id) this.dalManager.task.clearCaches this.dal.clearCaches - // Surface an honest receipt of what the recompute did. `tasksWritten` - // is the net change in the task count at each tier (post minus pre): - // positive means tasks were promoted into the tier, negative means - // tasks left it. All zeros means the distribution didn't shift — - // either nothing matched, or movements perfectly offset. val postCounts: Map[Int, Long] = this.dal.countTasksByPriority(id) val highCount: Long = postCounts.getOrElse(Challenge.PRIORITY_HIGH, 0L) val mediumCount: Long = postCounts.getOrElse(Challenge.PRIORITY_MEDIUM, 0L) diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index f4daec0ba..6093839e6 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -1085,12 +1085,12 @@ class ChallengeDAL @Inject() ( /** * Recomputes each task's priority from the challenge's current rules and bounds * using a single SQL UPDATE. Genuine failures raise: a missing challenge throws - * `NotFoundException`, lack of permission throws, and a DB error propagates. - * Returns a `(high, medium, low)` tuple of the *net change* in the task count at - * each priority tier (post-count minus pre-count); positive means tasks were - * promoted into that tier, negative means tasks left it. A `(0, 0, 0)` result - * means the distribution didn't shift (no valid rules/bounds, no matching tasks, - * or perfectly offsetting movements); it is not a failure signal. + * `NotFoundException`, lack of permission throws, an untranslatable rule throws + * `InvalidException`, and a DB error propagates. Returns a `(high, medium, low)` + * tuple of the number of task rows whose priority actually changed at each tier + * (no-op rows — those whose computed priority matches what's already stored — + * are not counted). A `(0, 0, 0)` result is legitimate (e.g. no valid rules, + * no tasks shifted), not a failure signal. * * @param user The user executing the request * @param id The id of the challenge @@ -1120,24 +1120,13 @@ class ChallengeDAL @Inject() ( Challenge.isValidBounds(challenge.priority.mediumPriorityBounds) || Challenge.isValidBounds(challenge.priority.lowPriorityBounds) - // make sure that at least one of the challenges is valid if (overrideValidation || hasRules || hasBounds) { - // Per-tier write counts aren't directly available from the single CASE - // UPDATE in recomputePriorities, so derive them from the change in the - // priority distribution. The receipt's `tasksWritten.X` reports the - // net change in tasks at tier X (positive = promoted into the tier, - // negative = moved out). All-zero deltas means the recompute didn't - // shift the distribution. - val preCounts = this.countTasksByPriority(id) - recomputePriorities(challenge) - val postCounts = this.countTasksByPriority(id) + val writes = recomputePriorities(challenge) this.taskDAL.clearCaches - def delta(p: Int): Int = - (postCounts.getOrElse(p, 0L) - preCounts.getOrElse(p, 0L)).toInt ( - delta(Challenge.PRIORITY_HIGH), - delta(Challenge.PRIORITY_MEDIUM), - delta(Challenge.PRIORITY_LOW) + writes.getOrElse(Challenge.PRIORITY_HIGH, 0L).toInt, + writes.getOrElse(Challenge.PRIORITY_MEDIUM, 0L).toInt, + writes.getOrElse(Challenge.PRIORITY_LOW, 0L).toInt ) } else { (0, 0, 0) @@ -1145,12 +1134,36 @@ class ChallengeDAL @Inject() ( } } + // Persists the recomputed priority for every task in the challenge in a single + // data-modifying CTE: the UPDATE is filtered by `IS DISTINCT FROM` so rows that + // already match the computed priority aren't rewritten, and RETURNING streams + // the post-update priority of the rows that actually changed so we can group + // them by tier. Returns count of rows written at each priority tier. + private def recomputePriorities( + challenge: Challenge + )(implicit id: Long, c: Connection): Map[Int, Long] = { + val (expr, params) = buildPriorityCaseExpression(challenge) + val allParams = params :+ NamedParameter("pid", id) + SQL( + s"""WITH updated AS ( + UPDATE tasks SET priority = $expr + WHERE parent_id = {pid} AND priority IS DISTINCT FROM ($expr) + RETURNING priority + ) + SELECT priority, COUNT(*) AS cnt FROM updated GROUP BY priority""" + ).on(allParams: _*) + .as( + (SqlParser.int("priority") ~ SqlParser.long("cnt")).map { case p ~ cnt => (p, cnt) }.* + ) + .toMap + } + // User-supplied strings (GeoJSON bounds, rule keys/values) must be bound as // anorm parameters rather than inlined: the Postgres JDBC driver pre-parses // statement text for ODBC-style {…} escape syntax, and GeoJSON contains {}. - private def recomputePriorities( + private def buildPriorityCaseExpression( challenge: Challenge - )(implicit id: Long, c: Connection): Unit = { + ): (String, Seq[NamedParameter]) = { val default = challenge.priority.defaultPriority val params = scala.collection.mutable.ListBuffer.empty[NamedParameter] @@ -1196,10 +1209,15 @@ class ChallengeDAL @Inject() ( } catch { case scala.util.control.NonFatal(_) => None } def boundsRuleSql(valueRaw: String, operator: String): Option[String] = { - val bbox = valueRaw.split(",").map(_.trim.toDouble) - if (bbox.length != 4) None + // `Try` rejects unparseable parts and `_.isFinite` rejects NaN/±Infinity — + // both would otherwise inline as invalid SQL and abort the whole UPDATE. + val parsed = valueRaw + .split(",") + .map(s => scala.util.Try(s.trim.toDouble).toOption.filter(_.isFinite)) + if (parsed.length != 4 || parsed.exists(_.isEmpty)) None else { - val env = s"ST_MakeEnvelope(${bbox(0)}, ${bbox(1)}, ${bbox(2)}, ${bbox(3)}, 4326)" + val bbox = parsed.map(_.get) + val env = s"ST_MakeEnvelope(${bbox(0)}, ${bbox(1)}, ${bbox(2)}, ${bbox(3)}, 4326)" operator match { case "contains" => Some(s"(location IS NOT NULL AND location && $env)") case "not_contains" => Some(s"(location IS NOT NULL AND NOT (location && $env))") @@ -1228,7 +1246,7 @@ class ChallengeDAL @Inject() ( case ("double", op) => for { sqlOp <- numericOp(op) - n <- scala.util.Try(rawValue.toDouble).toOption + n <- scala.util.Try(rawValue.toDouble).toOption.filter(_.isFinite) } yield s"(p.v ~ '$doubleRegex' AND p.v::double precision $sqlOp $n)" case ("integer" | "long", op) => for { @@ -1261,7 +1279,7 @@ class ChallengeDAL @Inject() ( boundsOpt.flatMap(boundsSql), ruleOpt.filter(r => Challenge.isValidRule(Some(r))).map { r => ruleSql(Json.parse(r)).getOrElse( - throw new IllegalArgumentException(s"Priority rule can't be translated to SQL: $r") + throw new InvalidException(s"Priority rule can't be translated to SQL: $r") ) } ).flatten @@ -1290,10 +1308,7 @@ class ChallengeDAL @Inject() ( if (whens.isEmpty) default.toString else s"CASE ${whens.mkString(" ")} ELSE $default END" - params += NamedParameter("pid", id) - SQL(s"UPDATE tasks SET priority = $expr WHERE parent_id = {pid}") - .on(params.toSeq: _*) - .executeUpdate() + (expr, params.toSeq) } private def numericOp(op: String): Option[String] = op match { @@ -1324,10 +1339,10 @@ class ChallengeDAL @Inject() ( /** * Dry-run `updateTaskPriorities`: computes, but does NOT persist, the priority * that every task in the challenge would receive under the supplied draft - * priority config. Used by the editor preview so the UI can show tier - * membership that is byte-for-byte consistent with what a subsequent save - * would write — including rule-based matches, which the frontend can't - * evaluate because it doesn't ship per-task OSM tags. + * priority config. Routes through the same SQL CASE expression the save path + * uses, so the editor preview is byte-for-byte consistent with what a + * subsequent save would write — including rule-based matches, which the + * frontend can't evaluate because it doesn't ship per-task OSM tags. */ def previewTaskPriorities( user: User, @@ -1343,23 +1358,20 @@ class ChallengeDAL @Inject() ( ) } // Splice the draft priority config onto a copy of the persisted challenge - // so `task.getTaskPriority` sees the user's in-progress rules/bounds - // while still reading tasks from the live DB. - val draftChallenge = persisted.copy(priority = draft) - val result = scala.collection.mutable.LongMap[Int]() - var pointer = 0 - var currentTasks: List[Task] = List.empty - do { - currentTasks = listChildren(DEFAULT_NUM_CHILDREN_LIST, pointer) - currentTasks.foreach { task => - val p = - try task.getTaskPriority(draftChallenge) - catch { case _: Exception => task.priority } - result.put(task.id, p) - } - pointer += 1 - } while (currentTasks.size >= DEFAULT_NUM_CHILDREN_LIST) - result.toMap + // so the CASE translation sees the user's in-progress rules/bounds while + // still reading tasks from the live DB. + val draftChallenge = persisted.copy(priority = draft) + val (expr, params) = buildPriorityCaseExpression(draftChallenge) + val allParams = params :+ NamedParameter("pid", id) + SQL( + s"SELECT id, ($expr) AS computed_priority FROM tasks WHERE parent_id = {pid}" + ).on(allParams: _*) + .as( + (SqlParser.long("id") ~ SqlParser.int("computed_priority")).map { + case i ~ p => (i, p) + }.* + ) + .toMap } } From 8751d8b47ea3b1b10e86a247ad70f1db20109402 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Fri, 5 Jun 2026 14:50:32 -0300 Subject: [PATCH 09/10] revert unnecessary changes --- .../controllers/api/ChallengeController.scala | 5 +++++ app/org/maproulette/models/dal/ChallengeDAL.scala | 10 ++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/org/maproulette/controllers/api/ChallengeController.scala b/app/org/maproulette/controllers/api/ChallengeController.scala index 65dd51e0e..3aac7b7dc 100644 --- a/app/org/maproulette/controllers/api/ChallengeController.scala +++ b/app/org/maproulette/controllers/api/ChallengeController.scala @@ -1959,6 +1959,11 @@ class ChallengeController @Inject() ( this.dal.updateTaskPriorities(user, overrideValidation = true)(id) this.dalManager.task.clearCaches this.dal.clearCaches + // Surface an honest receipt of what the recompute did. `tasksUpdated` + // is the number of task rows the DB actually changed at each tier, + // measured by COUNT(*) after the writes commit. A response with all + // zeros is the signal that either no tasks matched any tier (default + // priority covers everything) or the recompute short-circuited. val postCounts: Map[Int, Long] = this.dal.countTasksByPriority(id) val highCount: Long = postCounts.getOrElse(Challenge.PRIORITY_HIGH, 0L) val mediumCount: Long = postCounts.getOrElse(Challenge.PRIORITY_MEDIUM, 0L) diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index 211473435..79bf5daad 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -1037,16 +1037,10 @@ class ChallengeDAL @Inject() ( } } } + // update the task priorities in the background if (updatedPriorityRules) { Future { - try updateTaskPriorities(user, overrideValidation = true) - catch { - case t: Throwable => - logger.error( - s"updateTaskPriorities failed for challenge $id: ${t.getClass.getName}: ${t.getMessage}", - t - ) - } + updateTaskPriorities(user, overrideValidation = true) } } From 7d32e1f141550707f3d91d69399366172cf2796f Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Fri, 5 Jun 2026 17:46:30 -0300 Subject: [PATCH 10/10] - Simplified the `updateTaskPriorities` method to remove the return of task count tuples, now returning `Unit` instead. - Updated the `ChallengeController` to reflect changes in the priority update process, focusing on the post-recompute priority distribution. - Enhanced comments for clarity regarding the priority distribution and the implications of zero counts. - Streamlined the SQL logic in `recomputePriorities` to improve efficiency and maintainability. --- .../controllers/api/ChallengeController.scala | 17 +++---- .../maproulette/models/dal/ChallengeDAL.scala | 45 ++++++++++--------- 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/app/org/maproulette/controllers/api/ChallengeController.scala b/app/org/maproulette/controllers/api/ChallengeController.scala index 3aac7b7dc..fca705dc5 100644 --- a/app/org/maproulette/controllers/api/ChallengeController.scala +++ b/app/org/maproulette/controllers/api/ChallengeController.scala @@ -1955,25 +1955,18 @@ class ChallengeController @Inject() ( } else { this.dal.update(filtered, user)(id) match { case Some(updated) => - val (highWrites, mediumWrites, lowWrites) = - this.dal.updateTaskPriorities(user, overrideValidation = true)(id) + this.dal.updateTaskPriorities(user, overrideValidation = true)(id) this.dalManager.task.clearCaches this.dal.clearCaches - // Surface an honest receipt of what the recompute did. `tasksUpdated` - // is the number of task rows the DB actually changed at each tier, - // measured by COUNT(*) after the writes commit. A response with all - // zeros is the signal that either no tasks matched any tier (default - // priority covers everything) or the recompute short-circuited. + // Surface the post-recompute priority distribution so the editor + // can confirm what landed in the DB. All zeros means either no + // tasks matched any tier (default priority covers everything) or + // the recompute short-circuited. val postCounts: Map[Int, Long] = this.dal.countTasksByPriority(id) val highCount: Long = postCounts.getOrElse(Challenge.PRIORITY_HIGH, 0L) val mediumCount: Long = postCounts.getOrElse(Challenge.PRIORITY_MEDIUM, 0L) val lowCount: Long = postCounts.getOrElse(Challenge.PRIORITY_LOW, 0L) val receipt = Json.obj( - "tasksWritten" -> Json.obj( - "high" -> highWrites, - "medium" -> mediumWrites, - "low" -> lowWrites - ), "tasksByPriority" -> Json.obj( "high" -> highCount, "medium" -> mediumCount, diff --git a/app/org/maproulette/models/dal/ChallengeDAL.scala b/app/org/maproulette/models/dal/ChallengeDAL.scala index 79bf5daad..664c98ef4 100644 --- a/app/org/maproulette/models/dal/ChallengeDAL.scala +++ b/app/org/maproulette/models/dal/ChallengeDAL.scala @@ -1079,11 +1079,8 @@ class ChallengeDAL @Inject() ( * Recomputes each task's priority from the challenge's current rules and bounds * using a single SQL UPDATE. Genuine failures raise: a missing challenge throws * `NotFoundException`, lack of permission throws, an untranslatable rule throws - * `InvalidException`, and a DB error propagates. Returns a `(high, medium, low)` - * tuple of the number of task rows whose priority actually changed at each tier - * (no-op rows — those whose computed priority matches what's already stored — - * are not counted). A `(0, 0, 0)` result is legitimate (e.g. no valid rules, - * no tasks shifted), not a failure signal. + * `InvalidException`, and a DB error propagates. Callers that need a + * post-recompute priority distribution can read it with `countTasksByPriority`. * * @param user The user executing the request * @param id The id of the challenge @@ -1092,7 +1089,7 @@ class ChallengeDAL @Inject() ( def updateTaskPriorities( user: User, overrideValidation: Boolean = false - )(implicit id: Long, c: Option[Connection] = None): (Int, Int, Int) = { + )(implicit id: Long, c: Option[Connection] = None): Unit = { this.permission.hasWriteAccess(ChallengeType(), user) this.withMRConnection { implicit c => // Bypass the challenge cache so freshly-updated priority rules/bounds are @@ -1114,34 +1111,31 @@ class ChallengeDAL @Inject() ( Challenge.isValidBounds(challenge.priority.lowPriorityBounds) if (overrideValidation || hasRules || hasBounds) { - val writes = recomputePriorities(challenge) + recomputePriorities(challenge) this.taskDAL.clearCaches - ( - writes.getOrElse(Challenge.PRIORITY_HIGH, 0L).toInt, - writes.getOrElse(Challenge.PRIORITY_MEDIUM, 0L).toInt, - writes.getOrElse(Challenge.PRIORITY_LOW, 0L).toInt - ) - } else { - (0, 0, 0) } } } // Persists the recomputed priority for every task in the challenge in a single - // data-modifying CTE: the UPDATE is filtered by `IS DISTINCT FROM` so rows that - // already match the computed priority aren't rewritten, and RETURNING streams - // the post-update priority of the rows that actually changed so we can group - // them by tier. Returns count of rows written at each priority tier. + // statement: the `computed` CTE evaluates the CASE expression once per row, the + // UPDATE then filters by `IS DISTINCT FROM` so rows already at the computed + // priority aren't rewritten (which would fire triggers needlessly), and + // RETURNING streams the post-update priority of the rows that actually changed + // so we can group them by tier. Returns count of rows written at each tier. private def recomputePriorities( challenge: Challenge )(implicit id: Long, c: Connection): Map[Int, Long] = { val (expr, params) = buildPriorityCaseExpression(challenge) val allParams = params :+ NamedParameter("pid", id) SQL( - s"""WITH updated AS ( - UPDATE tasks SET priority = $expr - WHERE parent_id = {pid} AND priority IS DISTINCT FROM ($expr) - RETURNING priority + s"""WITH computed AS ( + SELECT id, ($expr) AS priority FROM tasks WHERE parent_id = {pid} + ), updated AS ( + UPDATE tasks SET priority = computed.priority FROM computed + WHERE tasks.id = computed.id + AND tasks.priority IS DISTINCT FROM computed.priority + RETURNING tasks.priority ) SELECT priority, COUNT(*) AS cnt FROM updated GROUP BY priority""" ).on(allParams: _*) @@ -1177,6 +1171,13 @@ class ChallengeDAL @Inject() ( ) } + // NOTE: For multi-feature tasks (FeatureCollections), this differs subtly + // from `Task.scala`'s in-memory evaluator. The frontend asks "does any one + // feature satisfy the whole compound rule?" because each leaf check runs + // its own EXISTS over the features array, so `k1=v1 AND k2=v2` matches + // when one feature has `k1=v1` and a *different* feature has `k2=v2`. This + // only affects unusual GeoJSON (e.g. OSM relations modeled as multi- + // feature collections); single-feature tasks evaluate identically. def ruleSql(ruleJson: JsValue): Option[String] = { val joiner = if ((ruleJson \ "condition").asOpt[String].exists(_.equalsIgnoreCase("OR"))) " OR "