diff --git a/src/community/challenges.osl b/src/community/challenges.osl
new file mode 100644
index 0000000..327d73d
--- /dev/null
+++ b/src/community/challenges.osl
@@ -0,0 +1,485 @@
+def challengeArray(object space, string field) array (
+ if space.contains(field) and space[field] != null (
+ return space[field].assertElse(array, [])
+ )
+ return []
+)
+
+def challengeCriteria(object space) array (
+ array criteria = challengeArray(space, "criteria")
+ if criteria.len == 0 (
+ return [{id: "overall", name: "Overall", description: "How strong is the entry as a whole?", weight: 1}]
+ )
+ array normalized = []
+ for i criteria.len (
+ object criterion = criteria[i].assert(object)
+ criterion.weight = clampNumber(criterion.weight.toNum(), 1, 5)
+ normalized.append(criterion)
+ )
+ return normalized
+)
+
+def challengePhase(object space) string (
+ if space.contains("resultsPublishedAt") and space.resultsPublishedAt.toNum() > 0 (
+ return "results"
+ )
+ number startsAt = space.startsAt.toNum()
+ number endsAt = space.endsAt.toNum()
+ number judgingEndsAt = 0
+ if space.contains("judgingEndsAt") (
+ judgingEndsAt = space.judgingEndsAt.toNum()
+ )
+ if startsAt > 0 and timestamp < startsAt (
+ return "upcoming"
+ )
+ if endsAt == 0 or timestamp <= endsAt (
+ return "submissions"
+ )
+ if judgingEndsAt == 0 or timestamp <= judgingEndsAt (
+ return "judging"
+ )
+ return "awaiting-results"
+)
+
+def challengeVotingMode(object space) string (
+ if space.contains("votingMode") and space.votingMode.toStr() == "audience" (
+ return "audience"
+ )
+ return "judges"
+)
+
+def challengeAudienceJudged(object space) boolean (
+ return challengeVotingMode(space) == "audience"
+)
+
+def challengeAudienceVotingEnabled(object space) boolean (
+ return challengeAudienceJudged(space) or (space.contains("communityVoting") and space.communityVoting == true)
+)
+
+def challengeJudges(object space) array (
+ if challengeAudienceJudged(space) (
+ return []
+ )
+ array judges = challengeArray(space, "judges")
+ string tag = groupTagOf(space)
+ if tag != "" (
+ array members = groupMemberNames(roturGroupSnapshot(tag))
+ for i members.len (
+ string member = members[i].toStr()
+ if !stringArrayContainsUser(judges, member) (
+ judges.append(member)
+ )
+ )
+ )
+ return judges
+)
+
+def isChallengeJudge(object space, string username) boolean (
+ return stringArrayContainsUser(challengeJudges(space), username)
+)
+
+def isChallengeJudgeInvitee(object space, string username) boolean (
+ array invites = challengeArray(space, "judgeInvites")
+ for i invites.len (
+ object invite = invites[i].assert(object)
+ if normalizeUsername(invite.username.toStr()) == normalizeUsername(username) (
+ return true
+ )
+ )
+ return false
+)
+
+def challengeScoreSummary(object space, string projectId, string viewer) object (
+ array criteria = challengeCriteria(space)
+ array scores = challengeArray(space, "scores")
+ number total = 0
+ number scoreCount = 0
+ object myScore = {}
+ array entryScores = []
+ for i scores.len (
+ object score = scores[i].assert(object)
+ if score.projectId.toStr() == projectId (
+ entryScores.append(score)
+ number weighted = 0
+ number weightTotal = 0
+ array ratings = score.ratings.assertElse(array, [])
+ for r ratings.len (
+ object rating = ratings[r].assert(object)
+ for k criteria.len (
+ object criterion = criteria[k].assert(object)
+ if criterion.id.toStr() == rating.criterionId.toStr() (
+ number weight = criterion.weight.toNum()
+ weighted += (clampNumber(rating.value.toNum(), 1, 10) * weight)
+ weightTotal += weight
+ )
+ )
+ )
+ if weightTotal > 0 (
+ total += (weighted / weightTotal)
+ scoreCount += 1
+ )
+ if normalizeUsername(score.judge.toStr()) == normalizeUsername(viewer) (
+ myScore = score
+ )
+ )
+ )
+ number audienceTotal = 0
+ number audienceCount = 0
+ number myVote = 0
+ array votes = challengeArray(space, "audienceVotes")
+ for i votes.len (
+ object vote = votes[i].assert(object)
+ if vote.projectId.toStr() == projectId (
+ audienceTotal += vote.value.toNum()
+ audienceCount += 1
+ if normalizeUsername(vote.username.toStr()) == normalizeUsername(viewer) (
+ myVote = vote.value.toNum()
+ )
+ )
+ )
+ number judgeScore = 0
+ number audienceScore = 0
+ if scoreCount > 0 (
+ judgeScore = total / scoreCount
+ )
+ if audienceCount > 0 (
+ audienceScore = audienceTotal / audienceCount
+ )
+ return {judgeScore: judgeScore, judgeScoreCount: scoreCount, audienceScore: audienceScore, audienceVoteCount: audienceCount, myScore: myScore, myVote: myVote, scores: entryScores}
+)
+
+def challengeProjects(object space,*serve.Context c) array (
+ array projects = []
+ array ids = spaceProjectIds(space)
+ boolean showResults = (space.contains("resultsPublishedAt") and space.resultsPublishedAt.toNum() > 0) or canManageSpace(space, c)
+ boolean audienceJudged = challengeAudienceJudged(space)
+ string phase = challengePhase(space)
+ boolean revealRatings = phase == "awaiting-results" or phase == "results" or canManageSpace(space, c)
+ for i ids.len (
+ auto loaded = loadProject(ids[i].toStr())
+ if loaded.ok == true (
+ object project = loaded.project.assert(object)
+ if project.shared == true or projectVisibility(project) == "unlisted" or canManageSpace(space, c) (
+ object entry = indexEntryFromProject(project)
+ object summary = challengeScoreSummary(space, project.id.toStr(), contextString(c, "username"))
+ entry.judgeScore = if showResults then summary.judgeScore else 0
+ entry.judgeScoreCount = summary.judgeScoreCount
+ entry.audienceScore = if revealRatings then summary.audienceScore else 0
+ entry.audienceVoteCount = if revealRatings then summary.audienceVoteCount else 0
+ entry.rankScore = entry.judgeScore
+ if audienceJudged and showResults (
+ entry.rankScore = (((summary.audienceScore.toNum() * 100) + 0.5).toInt() * 1000000) + clampNumber(summary.audienceVoteCount.toNum(), 0, 999999)
+ )
+ entry.myVote = summary.myVote
+ entry.myScore = if isChallengeJudge(space, contextString(c, "username")) then summary.myScore else {}
+ entry.scoreBreakdown = if canManageSpace(space, c) then summary.scores else []
+ projects.append(entry)
+ )
+ )
+ )
+ projects = projectsVisibleTo(projects, c)
+ if showResults (
+ projects = rankChallengeEntries(projects)
+ )
+ return projects
+)
+
+def challengeWinner(object space, array projects) object (
+ if challengePhase(space) != "results" (
+ return {}
+ )
+ for i projects.len (
+ object entry = projects[i].assert(object)
+ if entry.contains("place") and entry.place.toNum() == 1 (
+ boolean audienceJudged = challengeAudienceJudged(space)
+ return {
+ id: entry.id,
+ title: entry.title,
+ owner: entry.owner,
+ thumbUrl: entry.thumbUrl,
+ vanitySlug: entry.vanitySlug,
+ score: if audienceJudged then entry.audienceScore else entry.judgeScore,
+ scoreOutOf: if audienceJudged then 5 else 10,
+ voteCount: entry.audienceVoteCount
+ }
+ )
+ )
+ return {}
+)
+
+def handleInviteChallengeJudge(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or space.kind.toStr() != "challenge" (
+ c.notFound("challenge not found")
+ return
+ )
+ if !canManageSpace(space, c) (
+ c.forbidden("you cannot manage this challenge")
+ return
+ )
+ if challengeAudienceJudged(space) (
+ c.badRequest("this challenge is decided by audience ratings")
+ return
+ )
+ string requested = requestJSON(c).username.toStr().strip()
+ string usernameKey = normalizeUsername(requested)
+ object profile = (profileSearchDocs.findById(usernameKey) ?? {})
+ if usernameKey == "" or profile.len == 0 (
+ c.notFound("user not found")
+ return
+ )
+ if normalizeUsername(space.owner.toStr()) == usernameKey or isChallengeJudge(space, requested) or isChallengeJudgeInvitee(space, requested) (
+ c.badRequest("this user is already on the challenge team")
+ return
+ )
+ object invite = {username: profile.username, invitedBy: contextString(c, "username"), created: timestamp}
+ array invites = challengeArray(space, "judgeInvites")
+ invites.append(invite)
+ space.judgeInvites = invites
+ space.edited = timestamp
+ putSpaceDocument(space)
+ addNotification(profile.username.toStr(), contextString(c, "username"), "challenge_judge_invite", {spaceId: space["_id"], spaceTitle: space.title})
+ c.json(201, {ok: true, invitation: invite})
+)
+
+def handleJoinChallenge(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or space.kind.toStr() != "challenge" or !canViewSpace(space, c) (
+ c.notFound("challenge not found")
+ return
+ )
+ string phase = challengePhase(space)
+ if phase != "upcoming" and phase != "submissions" (
+ c.badRequest("this challenge is no longer open to new participants")
+ return
+ )
+ array participants = challengeArray(space, "participants")
+ if !stringArrayContainsUser(participants, contextString(c, "username")) (
+ participants.append(contextString(c, "username"))
+ space.participants = participants
+ space.edited = timestamp
+ putSpaceDocument(space)
+ if !isSpaceOwner(space, c) (
+ addNotification(space.owner.toStr(), contextString(c, "username"), "challenge_join", {spaceId: space["_id"], spaceTitle: space.title})
+ )
+ )
+ c.json(200, {ok: true, joined: true, participantCount: participants.len})
+)
+
+def handleLeaveChallenge(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or space.kind.toStr() != "challenge" (
+ c.notFound("challenge not found")
+ return
+ )
+ if challengePhase(space) != "upcoming" and challengePhase(space) != "submissions" (
+ c.badRequest("participants are locked for this challenge")
+ return
+ )
+ string username = contextString(c, "username")
+ array participants = challengeArray(space, "participants")
+ if !stringArrayContainsUser(participants, username) (
+ c.json(200, {ok: true, joined: false, participantCount: participants.len})
+ return
+ )
+ array ids = spaceProjectIds(space)
+ for i ids.len (
+ object project = (projectSearchDocs.findById(ids[i].toStr()) ?? {})
+ if project.len > 0 and searchEntryOwnedByContext(project, c) (
+ c.badRequest("remove your submission before leaving the challenge")
+ return
+ )
+ )
+ space.participants = removeUser(participants, username)
+ space.edited = timestamp
+ putSpaceDocument(space)
+ c.json(200, {ok: true, joined: false, participantCount: space.participants.len})
+)
+
+def handleRespondChallengeJudgeInvite(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !isChallengeJudgeInvitee(space, contextString(c, "username")) (
+ c.notFound("judge invitation not found")
+ return
+ )
+ array invites = challengeArray(space, "judgeInvites")
+ array nextInvites = []
+ for i invites.len (
+ object invite = invites[i].assert(object)
+ if normalizeUsername(invite.username.toStr()) != normalizeUsername(contextString(c, "username")) (
+ nextInvites.append(invite)
+ )
+ )
+ space.judgeInvites = nextInvites
+ if requestJSON(c).accepted == true (
+ array judges = challengeArray(space, "judges")
+ if !stringArrayContainsUser(judges, contextString(c, "username")) (
+ judges.append(contextString(c, "username"))
+ )
+ space.judges = judges
+ addNotification(space.owner.toStr(), contextString(c, "username"), "challenge_judge_accepted", {spaceId: space["_id"], spaceTitle: space.title})
+ )
+ space.edited = timestamp
+ putSpaceDocument(space)
+ c.json(200, {ok: true, space: spaceResponse(space, c)})
+)
+
+def handleRemoveChallengeJudge(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !canManageSpace(space, c) (
+ c.forbidden("you cannot manage this challenge")
+ return
+ )
+ string username = c.param("username")
+ array judges = challengeArray(space, "judges")
+ boolean changed = stringArrayContainsUser(judges, username)
+ space.judges = removeUser(judges, username)
+ array scores = challengeArray(space, "scores")
+ array nextScores = []
+ for i scores.len (
+ object score = scores[i].assert(object)
+ if normalizeUsername(score.judge.toStr()) != normalizeUsername(username) (
+ nextScores.append(score)
+ )
+ )
+ if nextScores.len != scores.len (
+ changed = true
+ )
+ space.scores = nextScores
+ if changed (
+ space.edited = timestamp
+ putSpaceDocument(space)
+ )
+ c.json(200, {ok: true})
+)
+
+def validChallengeCriterion(object space, string criterionId) boolean (
+ array criteria = challengeCriteria(space)
+ for i criteria.len (
+ object criterion = criteria[i].assert(object)
+ if criterion.id.toStr() == criterionId (
+ return true
+ )
+ )
+ return false
+)
+
+def handleScoreChallengeEntry(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ string projectId = c.param("project")
+ if space.len == 0 or space.kind.toStr() != "challenge" or !spaceProjectIds(space).contains(projectId) (
+ c.notFound("challenge entry not found")
+ return
+ )
+ if !isChallengeJudge(space, contextString(c, "username")) (
+ c.forbidden("only invited judges can score entries")
+ return
+ )
+ if challengePhase(space) != "judging" (
+ c.badRequest("judging is not open")
+ return
+ )
+ object project = (projectSearchDocs.findById(projectId) ?? {})
+ if project.len > 0 and searchEntryOwnedByContext(project, c) (
+ c.forbidden("you cannot score your own entry")
+ return
+ )
+ object body = requestJSON(c)
+ array rawRatings = body.ratings.assertElse(array, [])
+ array ratings = []
+ array ratedCriteria = []
+ for i rawRatings.len (
+ object rawRating = rawRatings[i].assert(object)
+ string criterionId = rawRating.criterionId.toStr()
+ number value = rawRating.value.toNum()
+ if validChallengeCriterion(space, criterionId) and !ratedCriteria.contains(criterionId) and value >= 1 and value <= 10 (
+ ratings.append({criterionId: criterionId, value: value})
+ ratedCriteria.append(criterionId)
+ )
+ )
+ if ratings.len != challengeCriteria(space).len (
+ c.badRequest("score every criterion from 1 to 10")
+ return
+ )
+ string feedback = body.feedback.toStr().strip()
+ if feedback.len > 2000 (
+ c.badRequest("feedback is too long")
+ return
+ )
+ string judge = contextString(c, "username")
+ array scores = challengeArray(space, "scores")
+ array nextScores = []
+ for i scores.len (
+ object existing = scores[i].assert(object)
+ if existing.projectId.toStr() != projectId or normalizeUsername(existing.judge.toStr()) != normalizeUsername(judge) (
+ nextScores.append(existing)
+ )
+ )
+ object score = {projectId: projectId, judge: judge, ratings: ratings, feedback: feedback, edited: timestamp}
+ nextScores.append(score)
+ space.scores = nextScores
+ space.edited = timestamp
+ putSpaceDocument(space)
+ c.json(200, {ok: true, score: score})
+)
+
+def handleVoteChallengeEntry(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ string projectId = c.param("project")
+ if space.len == 0 or space.kind.toStr() != "challenge" or !spaceProjectIds(space).contains(projectId) (
+ c.notFound("challenge entry not found")
+ return
+ )
+ if !challengeAudienceVotingEnabled(space) or challengePhase(space) != "judging" (
+ c.badRequest("community voting is not open")
+ return
+ )
+ object project = (projectSearchDocs.findById(projectId) ?? {})
+ if project.len > 0 and searchEntryOwnedByContext(project, c) (
+ c.forbidden("you cannot vote for your own entry")
+ return
+ )
+ number value = requestJSON(c).value.toNum()
+ if value < 1 or value > 5 (
+ c.badRequest("rating must be between 1 and 5")
+ return
+ )
+ string username = contextString(c, "username")
+ array votes = challengeArray(space, "audienceVotes")
+ array nextVotes = []
+ for i votes.len (
+ object vote = votes[i].assert(object)
+ if vote.projectId.toStr() != projectId or normalizeUsername(vote.username.toStr()) != normalizeUsername(username) (
+ nextVotes.append(vote)
+ )
+ )
+ nextVotes.append({projectId: projectId, username: username, value: value, edited: timestamp})
+ space.audienceVotes = nextVotes
+ putSpaceDocument(space)
+ c.json(200, {ok: true, value: value})
+)
+
+def handlePublishChallengeResults(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or space.kind.toStr() != "challenge" or !canManageSpace(space, c) (
+ c.forbidden("you cannot manage this challenge")
+ return
+ )
+ if timestamp <= space.endsAt.toNum() (
+ c.badRequest("submissions must close before results can be published")
+ return
+ )
+ if challengeAudienceJudged(space) (
+ if challengeArray(space, "audienceVotes").len == 0 (
+ c.badRequest("at least one entry needs an audience rating")
+ return
+ )
+ ) else if challengeArray(space, "scores").len == 0 (
+ c.badRequest("at least one entry needs a judge score")
+ return
+ )
+ space.resultsPublishedAt = timestamp
+ space.edited = timestamp
+ putSpaceDocument(space)
+ c.json(200, {ok: true, space: spaceResponse(space, c)})
+)
diff --git a/src/community/community-tools.osl b/src/community/community-tools.osl
deleted file mode 100644
index 481d662..0000000
--- a/src/community/community-tools.osl
+++ /dev/null
@@ -1,3039 +0,0 @@
-export {
- creditPlaytimeSession,
- migrateSpaceMemberships,
- purgeUserCommunityRecords,
- purgeUserFromSpaces,
- recordProjectDiagnostic,
- removeProjectFromSpaces,
- removeProjectsFromSpaces,
- stringArrayContainsUser,
- syncSpaceMemberships,
- warmShareCard,
- xmlEscape
-}
-
-def stringArrayContainsUser(array users, string username) boolean (
- string wanted = normalizeUsername(username)
- for i users.len (
- if normalizeUsername(users[i].toStr()) == wanted (
- return true
- )
- )
- return false
-)
-
-def removeUser(array users, string username) array (
- string wanted = normalizeUsername(username)
- array next = []
- for i users.len (
- if normalizeUsername(users[i].toStr()) != wanted (
- next.append(users[i])
- )
- )
- return next
-)
-
-def validSpaceKind(string kind) boolean (
- return (kind == "studio") or (kind == "collection") or (kind == "challenge")
-)
-
-def validSpaceVisibility(string visibility) boolean (
- return (visibility == "public") or (visibility == "unlisted") or (visibility == "private")
-)
-
-def spaceManagers(object space) array (
- if space.contains("managers") (
- return space["managers"].assertElse(array, [])
- )
- return []
-)
-
-def spaceCuratorInvites(object space) array (
- if space.contains("curatorInvites") (
- return space["curatorInvites"].assertElse(array, [])
- )
- return []
-)
-
-def spaceLikes(object space) array (
- if space.contains("likes") (
- return space["likes"].assertElse(array, [])
- )
- return []
-)
-
-def spaceBrokenHearts(object space) array (
- if space.contains("brokenHearts") (
- return space["brokenHearts"].assertElse(array, [])
- )
- return []
-)
-
-def spaceProjectIds(object space) array (
- if space.contains("projects") and space.projects != null (
- return space.projects.assertElse(array, [])
- )
- return []
-)
-
-def spaceFollowers(object space) array (
- if space.contains("followers") and space.followers != null (
- return space.followers.assertElse(array, [])
- )
- return []
-)
-
-def spaceCommentKey(string id) string (
- return "space-" ++ id
-)
-
-def spaceThumbnailPath(string id) string (
- return thumbnailsDir ++ "space-" ++ id ++ ".png"
-)
-
-def spaceThumbnailUrl(object space) string (
- string path = spaceThumbnailPath(space["_id"].toStr())
- if !fs.exists(path) (
- return ""
- )
- return appURL ++ "/thumbnails/space-" ++ space["_id"].toStr() ++ ".png?t=" ++ space.edited.toStr()
-)
-
-def isSpaceOwner(object space,*serve.Context c) boolean (
- string ownerId = ""
- if space.contains("ownerId") and space.ownerId != null (
- ownerId = space.ownerId.toStr()
- )
- return contextBool(c, "isAdmin") or identityMatches(ownerId, space.owner.toStr(), contextString(c, "userId"), contextString(c, "username"))
-)
-
-def searchEntryOwnedByContext(object project,*serve.Context c) boolean (
- string ownerId = ""
- if project.contains("ownerId") and project.ownerId != null (
- ownerId = project.ownerId.toStr()
- )
- return identityMatches(ownerId, project.owner.toStr(), contextString(c, "userId"), contextString(c, "username"))
-)
-
-def isSpaceInvitee(object space,*serve.Context c) boolean (
- array invites = spaceCuratorInvites(space)
- for i invites.len (
- object invite = invites[i].assert(object)
- if normalizeUsername(invite.username.toStr()) == normalizeUsername(contextString(c, "username")) (
- return true
- )
- )
- return false
-)
-
-def canManageSpace(object space,*serve.Context c) boolean (
- if contextBool(c, "isAdmin") (
- return true
- )
- string username = contextString(c, "username")
- if isSpaceOwner(space, c) (
- return true
- )
- if canManageGroupContent(space, c) (
- return true
- )
- return stringArrayContainsUser(spaceManagers(space), username)
-)
-
-def canViewSpace(object space,*serve.Context c) boolean (
- if space.visibility.toStr() == "public" or space.visibility.toStr() == "unlisted" (
- return true
- )
- return canManageSpace(space, c) or isSpaceInvitee(space, c)
-)
-
-def challengeArray(object space, string field) array (
- if space.contains(field) and space[field] != null (
- return space[field].assertElse(array, [])
- )
- return []
-)
-
-def appendSpaceUserKey(array keys, string username) array (
- string key = normalizeUsername(username)
- if key != "" and !keys.contains(key) (
- keys.append(key)
- )
- return keys
-)
-
-def spaceRelatedUserKeys(object space) array (
- array keys = appendSpaceUserKey([], space.owner.toStr())
- array managers = spaceManagers(space)
- for i managers.len (
- keys = appendSpaceUserKey(keys, managers[i].toStr())
- )
- array followers = space.followers.assertElse(array, [])
- for i followers.len (
- keys = appendSpaceUserKey(keys, followers[i].toStr())
- )
- array curatorInvites = spaceCuratorInvites(space)
- for i curatorInvites.len (
- object invite = curatorInvites[i].assert(object)
- keys = appendSpaceUserKey(keys, invite.username.toStr())
- )
- if space.kind.toStr() == "challenge" (
- array participants = challengeArray(space, "participants")
- for i participants.len (
- keys = appendSpaceUserKey(keys, participants[i].toStr())
- )
- array judges = challengeArray(space, "judges")
- for i judges.len (
- keys = appendSpaceUserKey(keys, judges[i].toStr())
- )
- array judgeInvites = challengeArray(space, "judgeInvites")
- for i judgeInvites.len (
- object invite = judgeInvites[i].assert(object)
- keys = appendSpaceUserKey(keys, invite.username.toStr())
- )
- )
- return keys
-)
-
-def syncSpaceMemberships(object space) boolean (
- string spaceId = space["_id"].toStr()
- array keys = spaceRelatedUserKeys(space)
- string signature = json.stringify(keys)
- string stateId = "space:" ++ spaceId
- object state = (spaceMemberDocs.findById(stateId) ?? {})
- if state.len > 0 and state.signature.toStr() == signature (
- return true
- )
- array previous = spaceMemberDocs.where("spaceId", "==", spaceId).all()
- for i previous.len (
- object membership = previous[i].assert(object)
- void spaceMemberDocs.deleteOne({_id: membership["_id"]})
- )
- for i keys.len (
- string userKey = keys[i].toStr()
- void spaceMemberDocs.insertOne({_id: "member:" ++ userKey ++ ":" ++ spaceId, kind: "member", userKey: userKey, spaceId: spaceId})
- )
- return putDocument(spaceMemberDocs, stateId, {kind: "state", signature: signature})
-)
-
-def putSpaceDocument(object space) boolean (
- boolean saved = putDocument(spaceDocs, space["_id"].toStr(), space)
- if saved (
- syncSpaceMemberships(space)
- )
- return saved
-)
-
-def deleteSpaceMemberships(string spaceId) boolean (
- array memberships = spaceMemberDocs.where("spaceId", "==", spaceId).all()
- for i memberships.len (
- object membership = memberships[i].assert(object)
- void spaceMemberDocs.deleteOne({_id: membership["_id"]})
- )
- void spaceMemberDocs.deleteOne({_id: "space:" ++ spaceId})
- return true
-)
-
-def migrateSpaceMemberships() boolean (
- string migrationId = "migration:space-members-v1"
- if (spaceMemberDocs.findById(migrationId) ?? {}).len > 0 (
- return true
- )
- array previous = spaceMemberDocs.all()
- for i previous.len (
- object document = previous[i].assert(object)
- void spaceMemberDocs.deleteOne({_id: document["_id"]})
- )
- array spaces = spaceDocs.all()
- for i spaces.len (
- syncSpaceMemberships(spaces[i].assert(object))
- )
- return putDocument(spaceMemberDocs, migrationId, {kind: "migration", completed: timestamp})
-)
-
-def removeProjectsFromSpaces(array removedProjectIds) boolean (
- array spaces = spaceDocs.all()
- for i spaces.len (
- object space = spaces[i].assert(object)
- array storedProjectIds = space.projects.assertElse(array, [])
- array projectIds = []
- boolean changed = false
- for j storedProjectIds.len (
- if removedProjectIds.contains(storedProjectIds[j].toStr()) (
- changed = true
- ) else (
- projectIds.append(storedProjectIds[j])
- )
- )
- if changed (
- space.projects = projectIds
- array submissions = []
- array storedSubmissions = challengeArray(space, "submissions")
- for j storedSubmissions.len (
- object submission = storedSubmissions[j].assert(object)
- if !removedProjectIds.contains(submission.projectId.toStr()) (
- submissions.append(submission)
- )
- )
- space.submissions = submissions
- array scores = []
- array storedScores = challengeArray(space, "scores")
- for j storedScores.len (
- object score = storedScores[j].assert(object)
- if !removedProjectIds.contains(score.projectId.toStr()) (
- scores.append(score)
- )
- )
- space.scores = scores
- array votes = []
- array storedVotes = challengeArray(space, "audienceVotes")
- for j storedVotes.len (
- object vote = storedVotes[j].assert(object)
- if !removedProjectIds.contains(vote.projectId.toStr()) (
- votes.append(vote)
- )
- )
- space.audienceVotes = votes
- space.edited = timestamp
- putSpaceDocument(space)
- )
- )
- return true
-)
-
-def removeProjectFromSpaces(string projectId) boolean (
- return removeProjectsFromSpaces([projectId])
-)
-
-def purgeUserFromSpaces(string username) boolean (
- string key = normalizeUsername(username)
- array spaces = spaceDocs.all()
- for i spaces.len (
- object space = spaces[i].assert(object)
- string spaceId = space["_id"].toStr()
- if normalizeUsername(space.owner.toStr()) == key (
- void spaceDocs.deleteOne({_id: space["_id"]})
- deleteSpaceMemberships(spaceId)
- void fs.remove(commentsDir ++ spaceCommentKey(spaceId) ++ ".json")
- void fs.remove(commentsDir ++ spaceCommentKey(spaceId) ++ ".jsonl")
- void fs.remove(spaceThumbnailPath(spaceId))
- ) else (
- boolean changed = spaceRelatedUserKeys(space).contains(key)
- array likes = spaceLikes(space)
- array broken = spaceBrokenHearts(space)
- if stringArrayContainsUser(likes, key) or stringArrayContainsUser(broken, key) (
- changed = true
- )
- space.managers = removeUser(spaceManagers(space), key)
- space.followers = removeUser(space.followers.assertElse(array, []), key)
- space.likes = removeUser(likes, key)
- space.brokenHearts = removeUser(broken, key)
- array curatorInvites = []
- array storedCuratorInvites = spaceCuratorInvites(space)
- for j storedCuratorInvites.len (
- object invite = storedCuratorInvites[j].assert(object)
- if normalizeUsername(invite.username.toStr()) != key (
- curatorInvites.append(invite)
- )
- )
- space.curatorInvites = curatorInvites
- if space.kind.toStr() == "challenge" (
- space.participants = removeUser(challengeArray(space, "participants"), key)
- space.judges = removeUser(challengeArray(space, "judges"), key)
- array judgeInvites = []
- array storedJudgeInvites = challengeArray(space, "judgeInvites")
- for j storedJudgeInvites.len (
- object invite = storedJudgeInvites[j].assert(object)
- if normalizeUsername(invite.username.toStr()) != key (
- judgeInvites.append(invite)
- )
- )
- space.judgeInvites = judgeInvites
- array scores = []
- array storedScores = challengeArray(space, "scores")
- for j storedScores.len (
- object score = storedScores[j].assert(object)
- if normalizeUsername(score.judge.toStr()) != key (
- scores.append(score)
- ) else (
- changed = true
- )
- )
- space.scores = scores
- array votes = []
- array storedVotes = challengeArray(space, "audienceVotes")
- for j storedVotes.len (
- object vote = storedVotes[j].assert(object)
- if normalizeUsername(vote.username.toStr()) != key (
- votes.append(vote)
- ) else (
- changed = true
- )
- )
- space.audienceVotes = votes
- )
- if changed (
- space.edited = timestamp
- putSpaceDocument(space)
- )
- )
- )
- return true
-)
-
-def purgeUserCommunityRecords(string username, string userId) boolean (
- string key = normalizeUsername(username)
- deleteDocumentsByField(reviewDocs, "authorKey", key)
- deleteDocumentsByField(feedbackDocs, "author", username)
- deleteDocumentsByField(playtimeDocs, "username", username)
- if userId != "" (
- deleteDocumentsByField(playtimeDocs, "userId", userId)
- deleteDocumentsByField(feedbackDocs, "authorId", userId)
- )
- void playtimeSessionDocs.deleteOne({_id: key})
-
- array releases = releaseDocs.where("creator", "==", username).all()
- for i releases.len (
- object release = releases[i].assert(object)
- release.creator = "Deleted User"
- putDocument(releaseDocs, release["_id"].toStr(), release)
- )
-
- array ideas = ideaDocs.all()
- for i ideas.len (
- object idea = ideas[i].assert(object)
- string ideaId = idea["_id"].toStr()
- if normalizeUsername(idea.author.toStr()) == key (
- void ideaDocs.deleteOne({_id: idea["_id"]})
- void fs.remove(commentsDir ++ "roadmap-" ++ ideaId ++ ".json")
- void fs.remove(commentsDir ++ "roadmap-" ++ ideaId ++ ".jsonl")
- ) else (
- array likes = []
- if idea.contains("likes") (
- likes = idea["likes"].assertElse(array, [])
- ) else if idea.contains("voters") (
- likes = idea["voters"].assertElse(array, [])
- )
- array dislikes = []
- if idea.contains("dislikes") (
- dislikes = idea["dislikes"].assertElse(array, [])
- )
- array nextLikes = removeUser(likes, key)
- array nextDislikes = removeUser(dislikes, key)
- if nextLikes.len != likes.len or nextDislikes.len != dislikes.len (
- idea.likes = nextLikes
- idea.dislikes = nextDislikes
- idea.voters = []
- idea.votes = nextLikes.len - nextDislikes.len
- putDocument(ideaDocs, ideaId, idea)
- )
- )
- )
- return true
-)
-
-def renameUserCommunityRecords(string oldKey, string newKey, string newName) boolean (
- array spaces = spaceDocs.all()
- for i spaces.len (
- object space = spaces[i].assert(object)
- boolean changed = spaceRelatedUserKeys(space).contains(oldKey)
- array likes = spaceLikes(space)
- array brokenHearts = spaceBrokenHearts(space)
- if stringArrayContainsUser(likes, oldKey) or stringArrayContainsUser(brokenHearts, oldKey) (
- changed = true
- )
- if normalizeUsername(space.owner.toStr()) == oldKey (
- space.owner = newName
- )
- space.managers = replaceUserInArray(spaceManagers(space), oldKey, newName)
- space.followers = replaceUserInArray(space.followers.assertElse(array, []), oldKey, newKey)
- space.likes = replaceUserInArray(likes, oldKey, newKey)
- space.brokenHearts = replaceUserInArray(brokenHearts, oldKey, newKey)
- array curatorInvites = spaceCuratorInvites(space)
- for j curatorInvites.len (
- object invite = curatorInvites[j].assert(object)
- if normalizeUsername(invite.username.toStr()) == oldKey (
- invite.username = newName
- changed = true
- )
- if normalizeUsername(invite.invitedBy.toStr()) == oldKey (
- invite.invitedBy = newName
- changed = true
- )
- curatorInvites[j] = invite
- )
- space.curatorInvites = curatorInvites
- if space.kind.toStr() == "challenge" (
- space.participants = replaceUserInArray(challengeArray(space, "participants"), oldKey, newName)
- space.judges = replaceUserInArray(challengeArray(space, "judges"), oldKey, newName)
- array judgeInvites = challengeArray(space, "judgeInvites")
- for j judgeInvites.len (
- object invite = judgeInvites[j].assert(object)
- if normalizeUsername(invite.username.toStr()) == oldKey (
- invite.username = newName
- changed = true
- )
- if normalizeUsername(invite.invitedBy.toStr()) == oldKey (
- invite.invitedBy = newName
- changed = true
- )
- judgeInvites[j] = invite
- )
- space.judgeInvites = judgeInvites
- array scores = challengeArray(space, "scores")
- for j scores.len (
- object score = scores[j].assert(object)
- if normalizeUsername(score.judge.toStr()) == oldKey (
- score.judge = newName
- scores[j] = score
- changed = true
- )
- )
- space.scores = scores
- array votes = challengeArray(space, "audienceVotes")
- for j votes.len (
- object vote = votes[j].assert(object)
- if normalizeUsername(vote.username.toStr()) == oldKey (
- vote.username = newName
- votes[j] = vote
- changed = true
- )
- )
- space.audienceVotes = votes
- array submissions = challengeArray(space, "submissions")
- for j submissions.len (
- object submission = submissions[j].assert(object)
- if normalizeUsername(submission.owner.toStr()) == oldKey (
- submission.owner = newName
- submissions[j] = submission
- changed = true
- )
- )
- space.submissions = submissions
- )
- if changed (
- putSpaceDocument(space)
- )
- )
-
- array reviews = reviewDocs.where("authorKey", "==", oldKey).all()
- for i reviews.len (
- object review = reviews[i].assert(object)
- string oldId = review["_id"].toStr()
- string newId = review.projectId.toStr() ++ ":" ++ newKey
- review.author = newName
- review.authorKey = newKey
- void reviewDocs.deleteOne({_id: oldId})
- putDocument(reviewDocs, newId, review)
- )
-
- array playtimeRecords = playtimeDocs.all()
- for i playtimeRecords.len (
- object playtime = playtimeRecords[i].assert(object)
- if normalizeUsername(playtime.username.toStr()) == oldKey (
- string oldId = playtime["_id"].toStr()
- string newId = playtime.projectId.toStr() ++ ":" ++ newKey
- object existing = (playtimeDocs.findById(newId) ?? {})
- if existing.len > 0 and newId != oldId (
- playtime.duration = playtime.duration.toNum() + existing.duration.toNum()
- )
- playtime.username = newName
- void playtimeDocs.deleteOne({_id: oldId})
- putDocument(playtimeDocs, newId, playtime)
- )
- )
- object session = (playtimeSessionDocs.findById(oldKey) ?? {})
- if session.len > 0 (
- session.username = newName
- void playtimeSessionDocs.deleteOne({_id: oldKey})
- putDocument(playtimeSessionDocs, newKey, session)
- )
-
- array feedback = feedbackDocs.all()
- for i feedback.len (
- object item = feedback[i].assert(object)
- if normalizeUsername(item.author.toStr()) == oldKey (
- item.author = newName
- putDocument(feedbackDocs, item["_id"].toStr(), item)
- )
- )
- array releases = releaseDocs.all()
- for i releases.len (
- object release = releases[i].assert(object)
- if normalizeUsername(release.creator.toStr()) == oldKey (
- release.creator = newName
- putDocument(releaseDocs, release["_id"].toStr(), release)
- )
- )
- array pulls = pullDocs.all()
- for i pulls.len (
- object pull = pulls[i].assert(object)
- boolean changed = false
- if normalizeUsername(pull.author.toStr()) == oldKey (
- pull.author = newName
- changed = true
- )
- if normalizeUsername(pull.sourceOwner.toStr()) == oldKey (
- pull.sourceOwner = newName
- changed = true
- )
- if changed (
- putDocument(pullDocs, pull["_id"].toStr(), pull)
- )
- )
-
- array ideas = ideaDocs.all()
- for i ideas.len (
- object idea = ideas[i].assert(object)
- boolean changed = false
- if normalizeUsername(idea.author.toStr()) == oldKey (
- idea.author = newName
- changed = true
- )
- array likes = []
- if idea.contains("likes") (
- likes = idea["likes"].assertElse(array, [])
- ) else if idea.contains("voters") (
- likes = idea["voters"].assertElse(array, [])
- )
- array dislikes = []
- if idea.contains("dislikes") (
- dislikes = idea["dislikes"].assertElse(array, [])
- )
- if stringArrayContainsUser(likes, oldKey) or stringArrayContainsUser(dislikes, oldKey) (
- idea.likes = replaceUserInArray(likes, oldKey, newName)
- idea.dislikes = replaceUserInArray(dislikes, oldKey, newName)
- idea.voters = []
- changed = true
- )
- if changed (
- putDocument(ideaDocs, idea["_id"].toStr(), idea)
- )
- )
- return true
-)
-
-def challengeCriteria(object space) array (
- array criteria = challengeArray(space, "criteria")
- if criteria.len == 0 (
- return [{id: "overall", name: "Overall", description: "How strong is the entry as a whole?", weight: 1}]
- )
- array normalized = []
- for i criteria.len (
- object criterion = criteria[i].assert(object)
- criterion.weight = clampNumber(criterion.weight.toNum(), 1, 5)
- normalized.append(criterion)
- )
- return normalized
-)
-
-def challengePhase(object space) string (
- if space.contains("resultsPublishedAt") and space.resultsPublishedAt.toNum() > 0 (
- return "results"
- )
- number startsAt = space.startsAt.toNum()
- number endsAt = space.endsAt.toNum()
- number judgingEndsAt = 0
- if space.contains("judgingEndsAt") (
- judgingEndsAt = space.judgingEndsAt.toNum()
- )
- if startsAt > 0 and timestamp < startsAt (
- return "upcoming"
- )
- if endsAt == 0 or timestamp <= endsAt (
- return "submissions"
- )
- if judgingEndsAt == 0 or timestamp <= judgingEndsAt (
- return "judging"
- )
- return "awaiting-results"
-)
-
-def challengeVotingMode(object space) string (
- if space.contains("votingMode") and space.votingMode.toStr() == "audience" (
- return "audience"
- )
- return "judges"
-)
-
-def challengeAudienceJudged(object space) boolean (
- return challengeVotingMode(space) == "audience"
-)
-
-def challengeAudienceVotingEnabled(object space) boolean (
- return challengeAudienceJudged(space) or (space.contains("communityVoting") and space.communityVoting == true)
-)
-
-def challengeJudges(object space) array (
- if challengeAudienceJudged(space) (
- return []
- )
- array judges = challengeArray(space, "judges")
- string tag = groupTagOf(space)
- if tag != "" (
- array members = groupMemberNames(roturGroupSnapshot(tag))
- for i members.len (
- string member = members[i].toStr()
- if !stringArrayContainsUser(judges, member) (
- judges.append(member)
- )
- )
- )
- return judges
-)
-
-def isChallengeJudge(object space, string username) boolean (
- return stringArrayContainsUser(challengeJudges(space), username)
-)
-
-def isChallengeJudgeInvitee(object space, string username) boolean (
- array invites = challengeArray(space, "judgeInvites")
- for i invites.len (
- object invite = invites[i].assert(object)
- if normalizeUsername(invite.username.toStr()) == normalizeUsername(username) (
- return true
- )
- )
- return false
-)
-
-def challengeScoreSummary(object space, string projectId, string viewer) object (
- array criteria = challengeCriteria(space)
- array scores = challengeArray(space, "scores")
- number total = 0
- number scoreCount = 0
- object myScore = {}
- array entryScores = []
- for i scores.len (
- object score = scores[i].assert(object)
- if score.projectId.toStr() == projectId (
- entryScores.append(score)
- number weighted = 0
- number weightTotal = 0
- array ratings = score.ratings.assertElse(array, [])
- for r ratings.len (
- object rating = ratings[r].assert(object)
- for k criteria.len (
- object criterion = criteria[k].assert(object)
- if criterion.id.toStr() == rating.criterionId.toStr() (
- number weight = criterion.weight.toNum()
- weighted += (clampNumber(rating.value.toNum(), 1, 10) * weight)
- weightTotal += weight
- )
- )
- )
- if weightTotal > 0 (
- total += (weighted / weightTotal)
- scoreCount += 1
- )
- if normalizeUsername(score.judge.toStr()) == normalizeUsername(viewer) (
- myScore = score
- )
- )
- )
- number audienceTotal = 0
- number audienceCount = 0
- number myVote = 0
- array votes = challengeArray(space, "audienceVotes")
- for i votes.len (
- object vote = votes[i].assert(object)
- if vote.projectId.toStr() == projectId (
- audienceTotal += vote.value.toNum()
- audienceCount += 1
- if normalizeUsername(vote.username.toStr()) == normalizeUsername(viewer) (
- myVote = vote.value.toNum()
- )
- )
- )
- number judgeScore = 0
- number audienceScore = 0
- if scoreCount > 0 (
- judgeScore = total / scoreCount
- )
- if audienceCount > 0 (
- audienceScore = audienceTotal / audienceCount
- )
- return {judgeScore: judgeScore, judgeScoreCount: scoreCount, audienceScore: audienceScore, audienceVoteCount: audienceCount, myScore: myScore, myVote: myVote, scores: entryScores}
-)
-
-def challengeProjects(object space,*serve.Context c) array (
- array projects = []
- array ids = spaceProjectIds(space)
- boolean showResults = (space.contains("resultsPublishedAt") and space.resultsPublishedAt.toNum() > 0) or canManageSpace(space, c)
- boolean audienceJudged = challengeAudienceJudged(space)
- string phase = challengePhase(space)
- boolean revealRatings = phase == "awaiting-results" or phase == "results" or canManageSpace(space, c)
- for i ids.len (
- auto loaded = loadProject(ids[i].toStr())
- if loaded.ok == true (
- object project = loaded.project.assert(object)
- if project.shared == true or projectVisibility(project) == "unlisted" or canManageSpace(space, c) (
- object entry = indexEntryFromProject(project)
- object summary = challengeScoreSummary(space, project.id.toStr(), contextString(c, "username"))
- entry.judgeScore = if showResults then summary.judgeScore else 0
- entry.judgeScoreCount = summary.judgeScoreCount
- entry.audienceScore = if revealRatings then summary.audienceScore else 0
- entry.audienceVoteCount = if revealRatings then summary.audienceVoteCount else 0
- entry.rankScore = entry.judgeScore
- if audienceJudged and showResults (
- entry.rankScore = (((summary.audienceScore.toNum() * 100) + 0.5).toInt() * 1000000) + clampNumber(summary.audienceVoteCount.toNum(), 0, 999999)
- )
- entry.myVote = summary.myVote
- entry.myScore = if isChallengeJudge(space, contextString(c, "username")) then summary.myScore else {}
- entry.scoreBreakdown = if canManageSpace(space, c) then summary.scores else []
- projects.append(entry)
- )
- )
- )
- projects = projectsVisibleTo(projects, c)
- if showResults (
- projects = rankChallengeEntries(projects)
- )
- return projects
-)
-
-def challengeWinner(object space, array projects) object (
- if challengePhase(space) != "results" (
- return {}
- )
- for i projects.len (
- object entry = projects[i].assert(object)
- if entry.contains("place") and entry.place.toNum() == 1 (
- boolean audienceJudged = challengeAudienceJudged(space)
- return {
- id: entry.id,
- title: entry.title,
- owner: entry.owner,
- thumbUrl: entry.thumbUrl,
- vanitySlug: entry.vanitySlug,
- score: if audienceJudged then entry.audienceScore else entry.judgeScore,
- scoreOutOf: if audienceJudged then 5 else 10,
- voteCount: entry.audienceVoteCount
- }
- )
- )
- return {}
-)
-
-def spaceTotalPlaytime(array ids) number (
- number total = 0
- for i ids.len (
- total += projectTotalPlaytime(ids[i].toStr())
- )
- return total
-)
-
-def spaceResponse(object space,*serve.Context c) object (
- if !space.contains("commentCount") (
- space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len
- putSpaceDocument(space)
- )
- array projects = []
- array ids = spaceProjectIds(space)
- if space.kind.toStr() == "challenge" (
- projects = challengeProjects(space, c)
- ) else (
- for i ids.len (
- auto loaded = loadProject(ids[i].toStr())
- if loaded.ok == true (
- object project = loaded.project.assert(object)
- if project.shared == true or projectVisibility(project) == "unlisted" or canManageSpace(space, c) (
- projects.append(indexEntryFromProject(project))
- )
- )
- )
- projects = projectsVisibleTo(projects, c)
- )
- object response = space
- response.projects = projects
- array visibleIds = []
- for i projects.len (
- object visibleProject = projects[i].assert(object)
- visibleIds.append(visibleProject.id)
- )
- response.projectIds = if canManageSpace(space, c) then ids else visibleIds
- response.canManage = canManageSpace(space, c)
- response.isOwner = isSpaceOwner(space, c)
- response.following = stringArrayContainsUser(spaceFollowers(space), contextString(c, "username"))
- response.followerCount = spaceFollowers(space).len
- response.invited = isSpaceInvitee(space, c)
- array likes = spaceLikes(space)
- array brokenHearts = spaceBrokenHearts(space)
- response.likeCount = likes.len
- response.brokenHeartCount = brokenHearts.len
- response.commentCount = space.commentCount
- response.myReaction = ""
- if stringArrayContainsUser(likes, contextString(c, "username")) (
- response.myReaction = "heart"
- ) else if stringArrayContainsUser(brokenHearts, contextString(c, "username")) (
- response.myReaction = "brokenheart"
- )
- response.likes = []
- response.brokenHearts = []
- response.curatorInvites = []
- response.thumbnailUrl = spaceThumbnailUrl(space)
- if space.kind.toStr() != "challenge" (
- response.totalPlaytimeMs = spaceTotalPlaytime(ids)
- )
- if space.kind.toStr() == "challenge" (
- if !response.contains("theme") (
- response.theme = ""
- )
- if !response.contains("rules") (
- response.rules = ""
- )
- if !response.contains("judgingEndsAt") (
- response.judgingEndsAt = space.endsAt.toNum() + 604800000
- )
- response.criteria = challengeCriteria(space)
- if !response.contains("communityVoting") (
- response.communityVoting = false
- )
- response.votingMode = challengeVotingMode(space)
- response.winner = challengeWinner(space, projects)
- if !response.contains("resultsPublishedAt") (
- response.resultsPublishedAt = 0
- )
- response.phase = challengePhase(space)
- response.isJudge = isChallengeJudge(space, contextString(c, "username"))
- response.judgeInvited = isChallengeJudgeInvitee(space, contextString(c, "username"))
- response.judgeCount = challengeJudges(space).len
- response.participantCount = challengeArray(space, "participants").len
- response.joined = stringArrayContainsUser(challengeArray(space, "participants"), contextString(c, "username"))
- response.judges = challengeJudges(space)
- response.judgeInvites = []
- response.scores = []
- response.audienceVotes = []
- response.participants = []
- )
- return response
-)
-
-def spaceListResponse(object space,*serve.Context c) object (
- array ids = spaceProjectIds(space)
- array followers = spaceFollowers(space)
- string thumbnailURL = spaceThumbnailUrl(space)
- number projectCount = 0
- boolean canManage = canManageSpace(space, c)
- for i ids.len (
- object project = (projectSearchDocs.findById(ids[i].toStr()) ?? {})
- boolean hidden = projectHiddenFrom(project, c)
- if project.len > 0 and !hidden and (project.shared == true or project.visibility.toStr() == "unlisted" or canManage) (
- projectCount += 1
- if thumbnailURL == "" and project.contains("thumbUrl") and thumbnailIsPublic(projectVisibility(project), false) (
- thumbnailURL = project.thumbUrl.toStr()
- )
- )
- )
- object response = {
- _id: space["_id"],
- title: space.title,
- description: space.description,
- kind: space.kind,
- visibility: space.visibility,
- owner: space.owner,
- groupTag: groupTagOf(space),
- edited: space.edited,
- startsAt: space.startsAt,
- endsAt: space.endsAt,
- openSubmissions: space.openSubmissions,
- projects: [],
- projectCount: projectCount,
- followers: [],
- followerCount: followers.len,
- thumbnailUrl: thumbnailURL,
- canManage: canManage,
- isOwner: isSpaceOwner(space, c),
- following: stringArrayContainsUser(followers, contextString(c, "username")),
- invited: isSpaceInvitee(space, c)
- }
- if space.kind.toStr() == "challenge" (
- response.participantCount = challengeArray(space, "participants").len
- ) else (
- response.totalPlaytimeMs = spaceTotalPlaytime(ids)
- )
- return response
-)
-
-def spaceManagementResponse(object space,*serve.Context c) object (
- array invites = spaceCuratorInvites(space)
- array judgeInvites = challengeArray(space, "judgeInvites")
- array participants = challengeArray(space, "participants")
- object response = spaceResponse(space, c)
- response.curatorInvites = invites
- if space.kind.toStr() == "challenge" (
- response.judgeInvites = judgeInvites
- response.participants = participants
- )
- return response
-)
-
-def handleListSpaces(*serve.Context c) (
- string kind = (c.query("kind") ?? "").toLower().strip()
- string q = (c.query("q") ?? "").toLower().strip()
- number limit = clampNumber(c.queryInt("limit", 24), 1, 100)
- number offset = c.queryInt("offset", 0)
- number startsBefore = c.queryInt("startsBefore", 0)
- number endsAfter = c.queryInt("endsAfter", 0)
- number resultsAfter = c.queryInt("resultsAfter", 0)
- array stored = []
-
- if q == "" and kind == "challenge" and resultsAfter > 0 (
- stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("resultsPublishedAt", ">=", resultsAfter).sort("resultsPublishedAt", "desc").limit(limit).all()
- array spaces = []
- for i stored.len (
- object published = stored[i].assert(object)
- object listed = spaceListResponse(published, c)
- listed.resultsPublishedAt = published.resultsPublishedAt
- listed.winner = challengeWinner(published, challengeProjects(published, c))
- if listed.winner.len > 0 (
- spaces.append(listed)
- )
- )
- c.json(200, {ok: true, total: spaces.len, spaces: spaces})
- return
- )
-
- if q == "" and kind == "challenge" and startsBefore > 0 and endsAfter > 0 (
- number total = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("startsAt", "<=", startsBefore).where("endsAt", ">=", endsAfter).count()
- offset = clampNumber(offset, 0, total)
- stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("startsAt", "<=", startsBefore).where("endsAt", ">=", endsAfter).sort("startsAt", "asc").limit(offset + limit).all()
- array page = paginate(stored, offset, limit)
- array spaces = []
- for i page.len (
- spaces.append(spaceListResponse(page[i].assert(object), c))
- )
- c.json(200, {ok: true, total: total, spaces: spaces})
- return
- )
-
- if q == "" (
- number total = 0
- if kind != "" (
- total = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).count()
- offset = clampNumber(offset, 0, total)
- stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).sort("edited", "desc").limit(offset + limit).all()
- ) else (
- total = spaceDocs.where("visibility", "==", "public").count()
- offset = clampNumber(offset, 0, total)
- stored = spaceDocs.where("visibility", "==", "public").sort("edited", "desc").limit(offset + limit).all()
- )
- array page = paginate(stored, offset, limit)
- array spaces = []
- for i page.len (
- spaces.append(spaceListResponse(page[i].assert(object), c))
- )
- c.json(200, {ok: true, total: total, spaces: spaces})
- return
- )
-
- if kind != "" (
- stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).sort("edited", "desc").all()
- ) else (
- stored = spaceDocs.where("visibility", "==", "public").sort("edited", "desc").all()
- )
- array matches = []
- for i stored.len (
- object space = stored[i].assert(object)
- boolean include = space.visibility.toStr() == "public"
- if include and kind != "" (
- include = space.kind.toStr() == kind
- )
- if include and q != "" (
- include = space.title.toStr().toLower().contains(q) or space.description.toStr().toLower().contains(q)
- )
- if include (
- number score = spaceSearchScore(space.title.toStr(), space.description.toStr(), space.owner.toStr(), q)
- matches.append({rank: searchRank(score, spaceFollowers(space).len), space: space})
- )
- )
- matches.sortBy("rank", "descending")
- number total = matches.len
- offset = clampNumber(offset, 0, total)
- array page = paginate(matches, offset, limit)
- array spaces = []
- for i page.len (
- object row = page[i].assert(object)
- spaces.append(spaceListResponse(row.space.assert(object), c))
- )
- c.json(200, {ok: true, total: total, spaces: spaces})
-)
-
-def handleGetSpace(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !canViewSpace(space, c) (
- c.notFound("space not found")
- return
- )
- c.json(200, {ok: true, space: spaceResponse(space, c)})
-)
-
-def handleCreateSpace(*serve.Context c) (
- object body = requestJSON(c)
- number ownedSpaces = spaceDocs.where("ownerId", "==", contextString(c, "userId")).count()
- if !contextBool(c, "isAdmin") and ownedSpaces >= mistwarpBenefitsForUserId(contextString(c, "userId")).maxSpaces.toNum() (
- c.json(413, {ok: false, error: "space limit reached; delete a space or upgrade your plan"})
- return
- )
- string groupTag = ""
- if body.contains("groupTag") and body.groupTag != null and body.groupTag.toStr().strip() != "" (
- groupTag = normalizeGroupTag(body.groupTag.toStr())
- if groupTag == "" (
- c.badRequest("invalid group tag")
- return
- )
- if !canManageGroupTag(groupTag, c) (
- c.forbidden("you cannot create spaces for this group")
- return
- )
- )
- string title = body.title.toStr().strip()
- string kind = body.kind.toStr().toLower().strip()
- if title == "" or title.len > 100 (
- c.badRequest("title must be between 1 and 100 characters")
- return
- )
- if !validSpaceKind(kind) (
- c.badRequest("invalid space type")
- return
- )
- string visibility = "public"
- if body.contains("visibility") (
- visibility = body.visibility.toStr().toLower()
- )
- if !validSpaceVisibility(visibility) (
- c.badRequest("invalid visibility")
- return
- )
- string description = ""
- if body.contains("description") (
- description = body.description.toStr().strip()
- )
- if description.len > 5000 (
- c.badRequest("description is too long")
- return
- )
- number startsAt = 0
- number endsAt = 0
- if body.contains("startsAt") (
- startsAt = body.startsAt.toNum()
- )
- if body.contains("endsAt") (
- endsAt = body.endsAt.toNum()
- )
- if kind == "challenge" and (startsAt <= 0 or endsAt <= startsAt) (
- c.badRequest("challenge dates must be in order")
- return
- )
- string votingMode = "judges"
- if body.contains("votingMode") (
- votingMode = body.votingMode.toStr().toLower().strip()
- )
- if votingMode != "judges" and votingMode != "audience" (
- c.badRequest("invalid voting mode")
- return
- )
- string id = "s" ++ randomString(12)
- object space = {
- _id: id,
- title: title,
- description: description,
- kind: kind,
- visibility: visibility,
- owner: contextString(c, "username"),
- ownerId: contextString(c, "userId"),
- groupTag: groupTag,
- managers: [],
- curatorInvites: [],
- projects: [],
- followers: [],
- likes: [],
- brokenHearts: [],
- openSubmissions: body.contains("openSubmissions") and body.openSubmissions == true,
- startsAt: startsAt,
- endsAt: endsAt,
- judgingEndsAt: endsAt + 604800000,
- theme: "",
- rules: "",
- criteria: [{id: "overall", name: "Overall", description: "How strong is the entry as a whole?", weight: 1}],
- judges: [],
- judgeInvites: [],
- scores: [],
- audienceVotes: [],
- submissions: [],
- participants: [],
- commentCount: 0,
- communityVoting: false,
- votingMode: votingMode,
- resultsPublishedAt: 0,
- created: timestamp,
- edited: timestamp
- }
- putSpaceDocument(space)
- c.json(201, {ok: true, space: spaceResponse(space, c)})
-)
-
-def handleUpdateSpace(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- if !canManageSpace(space, c) (
- c.forbidden("you cannot manage this space")
- return
- )
- object body = requestJSON(c)
- if body.contains("owner") (
- boolean canTransfer = isSpaceOwner(space, c) or canManageGroupContent(space, c)
- if !canTransfer (
- c.forbidden("you cannot transfer this space")
- return
- )
- string requestedOwner = body.owner.toStr().strip()
- string nextOwnerId = discoverUserIdentity(requestedOwner)
- if nextOwnerId == "" (
- c.badRequest("Rotur user not found")
- return
- )
- string nextOwner = usernameForId(nextOwnerId)
- if nextOwner == "" (
- c.badRequest("Rotur user not found")
- return
- )
- number nextOwnerLimit = mistwarpBenefitsForUserId(nextOwnerId).maxSpaces.toNum()
- if !identityMatches((space.ownerId ?? "").toStr(), space.owner.toStr(), nextOwnerId, nextOwner) and spaceDocs.where("ownerId", "==", nextOwnerId).count() >= nextOwnerLimit (
- c.badRequest("the new owner has reached their space limit")
- return
- )
- space.owner = nextOwner
- space.ownerId = nextOwnerId
- if !body.contains("keepGroup") or body.keepGroup != true (
- space.groupTag = ""
- )
- )
- if body.contains("groupTag") (
- string nextGroupTag = normalizeGroupTag(body.groupTag.toStr())
- if body.groupTag.toStr().strip() == "" (
- nextGroupTag = ""
- ) else if nextGroupTag == "" (
- c.badRequest("invalid group tag")
- return
- ) else if !canManageGroupTag(nextGroupTag, c) (
- c.forbidden("you cannot move this space into that group")
- return
- )
- space.groupTag = nextGroupTag
- )
- if body.contains("title") (
- string title = body.title.toStr().strip()
- if title == "" or title.len > 100 (
- c.badRequest("invalid title")
- return
- )
- space.title = title
- )
- if body.contains("description") (
- string description = body.description.toStr().strip()
- if description.len > 5000 (
- c.badRequest("description is too long")
- return
- )
- space.description = description
- )
- if body.contains("visibility") and validSpaceVisibility(body.visibility.toStr()) (
- space.visibility = body.visibility
- )
- if body.contains("openSubmissions") (
- space.openSubmissions = body.openSubmissions == true
- )
- if body.contains("startsAt") (
- space.startsAt = body.startsAt.toNum()
- )
- if body.contains("endsAt") (
- space.endsAt = body.endsAt.toNum()
- )
- if body.contains("judgingEndsAt") (
- space.judgingEndsAt = body.judgingEndsAt.toNum()
- )
- if body.contains("theme") (
- string theme = body.theme.toStr().strip()
- if theme.len > 200 (
- c.badRequest("theme is too long")
- return
- )
- space.theme = theme
- )
- if body.contains("rules") (
- string rules = body.rules.toStr().strip()
- if rules.len > 10000 (
- c.badRequest("rules are too long")
- return
- )
- space.rules = rules
- )
- if body.contains("communityVoting") (
- space.communityVoting = body.communityVoting == true
- )
- if body.contains("votingMode") and space.kind.toStr() == "challenge" (
- string votingMode = body.votingMode.toStr().toLower().strip()
- if votingMode != "judges" and votingMode != "audience" (
- c.badRequest("invalid voting mode")
- return
- )
- if votingMode != challengeVotingMode(space) and challengePhase(space) == "results" (
- c.badRequest("the voting mode cannot change after results are published")
- return
- )
- space.votingMode = votingMode
- )
- if body.contains("criteria") and space.kind.toStr() == "challenge" (
- if challengeArray(space, "scores").len > 0 (
- c.badRequest("judging criteria cannot change after scoring begins")
- return
- )
- array rawCriteria = body.criteria.assertElse(array, [])
- array criteria = []
- for i rawCriteria.len (
- object rawCriterion = rawCriteria[i].assert(object)
- string name = rawCriterion.name.toStr().strip()
- string description = rawCriterion.description.toStr().strip()
- if name != "" and name.len <= 60 and description.len <= 300 and criteria.len < 8 (
- string criterionId = rawCriterion.id.toStr().strip()
- if criterionId == "" (
- criterionId = "jc" ++ randomString(8)
- )
- criteria.append({id: criterionId, name: name, description: description, weight: clampNumber(rawCriterion.weight.toNum(), 1, 5)})
- )
- )
- if criteria.len == 0 (
- c.badRequest("add at least one judging criterion")
- return
- )
- space.criteria = criteria
- )
- if space.kind.toStr() == "challenge" and (space.startsAt.toNum() <= 0 or space.endsAt.toNum() <= space.startsAt.toNum() or space.judgingEndsAt.toNum() <= space.endsAt.toNum()) (
- c.badRequest("challenge dates must be in order")
- return
- )
- space.edited = timestamp
- putSpaceDocument(space)
- c.json(200, {ok: true, space: spaceResponse(space, c)})
-)
-
-def handleDeleteSpace(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- if !isSpaceOwner(space, c) (
- c.forbidden("only the owner can delete this space")
- return
- )
- void spaceDocs.deleteOne({_id: space["_id"]})
- deleteSpaceMemberships(space["_id"].toStr())
- void fs.remove(commentsDir ++ spaceCommentKey(space["_id"].toStr()) ++ ".json")
- void fs.remove(commentsDir ++ spaceCommentKey(space["_id"].toStr()) ++ ".jsonl")
- if fs.exists(spaceThumbnailPath(space["_id"].toStr())) (
- void fs.remove(spaceThumbnailPath(space["_id"].toStr()))
- )
- c.json(200, {ok: true})
-)
-
-def handleSetSpaceThumbnail(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- if space.kind.toStr() != "studio" or !canManageSpace(space, c) (
- c.forbidden("you cannot change this studio thumbnail")
- return
- )
- auto fileResult = c.formFile("thumbnail")
- if fileResult.isErr() (
- c.badRequest("missing thumbnail file")
- return
- )
- object thumbnail = fileResult.unwrap()
- if thumbnail.size.toNum() > maxThumbBytes (
- c.badRequest("thumbnail too large")
- return
- )
- if !fs.writeFileBytes(spaceThumbnailPath(space["_id"].toStr()), thumbnail.data) (
- c.internalError("thumbnail save failed")
- return
- )
- space.edited = timestamp
- putSpaceDocument(space)
- c.json(200, {ok: true, thumbnailUrl: spaceThumbnailUrl(space)})
-)
-
-def handleAddSpaceProject(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- object project = (projectSearchDocs.findById(c.param("project")) ?? {})
- if space.len == 0 or project.len == 0 (
- c.notFound("space or project not found")
- return
- )
- boolean projectVisible = project.shared == true or project.visibility.toStr() == "unlisted"
- if !projectVisible (
- c.forbidden("only shared or unlisted projects can be added")
- return
- )
- boolean allowed = canManageSpace(space, c)
- boolean ownsProject = searchEntryOwnedByContext(project, c)
- boolean participantCanSubmit = space.openSubmissions == true and ownsProject
- if space.kind.toStr() == "challenge" (
- participantCanSubmit = participantCanSubmit and challengePhase(space) == "submissions"
- )
- allowed = allowed or participantCanSubmit
- if !allowed (
- c.forbidden("submissions are closed")
- return
- )
- array ids = spaceProjectIds(space)
- if !ids.contains(project.id.toStr()) (
- ids.append(project.id.toStr())
- space.projects = ids
- if space.kind.toStr() == "challenge" (
- array submissions = challengeArray(space, "submissions")
- submissions.append({projectId: project.id, owner: project.owner, submittedAt: timestamp})
- space.submissions = submissions
- array participants = challengeArray(space, "participants")
- if !stringArrayContainsUser(participants, project.owner.toStr()) (
- participants.append(project.owner)
- )
- space.participants = participants
- )
- space.edited = timestamp
- putSpaceDocument(space)
- addNotification(space.owner.toStr(), contextString(c, "username"), "space_project", {spaceId: space["_id"], spaceTitle: space.title, projectId: project.id, projectTitle: project.title})
- )
- c.json(200, {ok: true, space: spaceResponse(space, c)})
-)
-
-def handleRemoveSpaceProject(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- object project = (projectSearchDocs.findById(c.param("project")) ?? {})
- boolean allowed = canManageSpace(space, c)
- if project.len > 0 (
- allowed = allowed or searchEntryOwnedByContext(project, c)
- )
- if !allowed (
- c.forbidden("you cannot remove this project")
- return
- )
- space.projects = removeString(spaceProjectIds(space), c.param("project"))
- if space.kind.toStr() == "challenge" (
- array submissions = challengeArray(space, "submissions")
- array nextSubmissions = []
- for i submissions.len (
- object submission = submissions[i].assert(object)
- if submission.projectId.toStr() != c.param("project") (
- nextSubmissions.append(submission)
- )
- )
- space.submissions = nextSubmissions
- )
- space.edited = timestamp
- putSpaceDocument(space)
- c.json(200, {ok: true, space: spaceResponse(space, c)})
-)
-
-def handleInviteChallengeJudge(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or space.kind.toStr() != "challenge" (
- c.notFound("challenge not found")
- return
- )
- if !canManageSpace(space, c) (
- c.forbidden("you cannot manage this challenge")
- return
- )
- if challengeAudienceJudged(space) (
- c.badRequest("this challenge is decided by audience ratings")
- return
- )
- string requested = requestJSON(c).username.toStr().strip()
- string usernameKey = normalizeUsername(requested)
- object profile = (profileSearchDocs.findById(usernameKey) ?? {})
- if usernameKey == "" or profile.len == 0 (
- c.notFound("user not found")
- return
- )
- if normalizeUsername(space.owner.toStr()) == usernameKey or isChallengeJudge(space, requested) or isChallengeJudgeInvitee(space, requested) (
- c.badRequest("this user is already on the challenge team")
- return
- )
- object invite = {username: profile.username, invitedBy: contextString(c, "username"), created: timestamp}
- array invites = challengeArray(space, "judgeInvites")
- invites.append(invite)
- space.judgeInvites = invites
- space.edited = timestamp
- putSpaceDocument(space)
- addNotification(profile.username.toStr(), contextString(c, "username"), "challenge_judge_invite", {spaceId: space["_id"], spaceTitle: space.title})
- c.json(201, {ok: true, invitation: invite})
-)
-
-def handleJoinChallenge(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or space.kind.toStr() != "challenge" or !canViewSpace(space, c) (
- c.notFound("challenge not found")
- return
- )
- string phase = challengePhase(space)
- if phase != "upcoming" and phase != "submissions" (
- c.badRequest("this challenge is no longer open to new participants")
- return
- )
- array participants = challengeArray(space, "participants")
- if !stringArrayContainsUser(participants, contextString(c, "username")) (
- participants.append(contextString(c, "username"))
- space.participants = participants
- space.edited = timestamp
- putSpaceDocument(space)
- if !isSpaceOwner(space, c) (
- addNotification(space.owner.toStr(), contextString(c, "username"), "challenge_join", {spaceId: space["_id"], spaceTitle: space.title})
- )
- )
- c.json(200, {ok: true, joined: true, participantCount: participants.len})
-)
-
-def handleLeaveChallenge(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or space.kind.toStr() != "challenge" (
- c.notFound("challenge not found")
- return
- )
- if challengePhase(space) != "upcoming" and challengePhase(space) != "submissions" (
- c.badRequest("participants are locked for this challenge")
- return
- )
- string username = contextString(c, "username")
- array participants = challengeArray(space, "participants")
- if !stringArrayContainsUser(participants, username) (
- c.json(200, {ok: true, joined: false, participantCount: participants.len})
- return
- )
- array ids = spaceProjectIds(space)
- for i ids.len (
- object project = (projectSearchDocs.findById(ids[i].toStr()) ?? {})
- if project.len > 0 and searchEntryOwnedByContext(project, c) (
- c.badRequest("remove your submission before leaving the challenge")
- return
- )
- )
- space.participants = removeUser(participants, username)
- space.edited = timestamp
- putSpaceDocument(space)
- c.json(200, {ok: true, joined: false, participantCount: space.participants.len})
-)
-
-def handleRespondChallengeJudgeInvite(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !isChallengeJudgeInvitee(space, contextString(c, "username")) (
- c.notFound("judge invitation not found")
- return
- )
- array invites = challengeArray(space, "judgeInvites")
- array nextInvites = []
- for i invites.len (
- object invite = invites[i].assert(object)
- if normalizeUsername(invite.username.toStr()) != normalizeUsername(contextString(c, "username")) (
- nextInvites.append(invite)
- )
- )
- space.judgeInvites = nextInvites
- if requestJSON(c).accepted == true (
- array judges = challengeArray(space, "judges")
- if !stringArrayContainsUser(judges, contextString(c, "username")) (
- judges.append(contextString(c, "username"))
- )
- space.judges = judges
- addNotification(space.owner.toStr(), contextString(c, "username"), "challenge_judge_accepted", {spaceId: space["_id"], spaceTitle: space.title})
- )
- space.edited = timestamp
- putSpaceDocument(space)
- c.json(200, {ok: true, space: spaceResponse(space, c)})
-)
-
-def handleRemoveChallengeJudge(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !canManageSpace(space, c) (
- c.forbidden("you cannot manage this challenge")
- return
- )
- string username = c.param("username")
- array judges = challengeArray(space, "judges")
- boolean changed = stringArrayContainsUser(judges, username)
- space.judges = removeUser(judges, username)
- array scores = challengeArray(space, "scores")
- array nextScores = []
- for i scores.len (
- object score = scores[i].assert(object)
- if normalizeUsername(score.judge.toStr()) != normalizeUsername(username) (
- nextScores.append(score)
- )
- )
- if nextScores.len != scores.len (
- changed = true
- )
- space.scores = nextScores
- if changed (
- space.edited = timestamp
- putSpaceDocument(space)
- )
- c.json(200, {ok: true})
-)
-
-def validChallengeCriterion(object space, string criterionId) boolean (
- array criteria = challengeCriteria(space)
- for i criteria.len (
- object criterion = criteria[i].assert(object)
- if criterion.id.toStr() == criterionId (
- return true
- )
- )
- return false
-)
-
-def handleScoreChallengeEntry(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- string projectId = c.param("project")
- if space.len == 0 or space.kind.toStr() != "challenge" or !spaceProjectIds(space).contains(projectId) (
- c.notFound("challenge entry not found")
- return
- )
- if !isChallengeJudge(space, contextString(c, "username")) (
- c.forbidden("only invited judges can score entries")
- return
- )
- if challengePhase(space) != "judging" (
- c.badRequest("judging is not open")
- return
- )
- object project = (projectSearchDocs.findById(projectId) ?? {})
- if project.len > 0 and searchEntryOwnedByContext(project, c) (
- c.forbidden("you cannot score your own entry")
- return
- )
- object body = requestJSON(c)
- array rawRatings = body.ratings.assertElse(array, [])
- array ratings = []
- array ratedCriteria = []
- for i rawRatings.len (
- object rawRating = rawRatings[i].assert(object)
- string criterionId = rawRating.criterionId.toStr()
- number value = rawRating.value.toNum()
- if validChallengeCriterion(space, criterionId) and !ratedCriteria.contains(criterionId) and value >= 1 and value <= 10 (
- ratings.append({criterionId: criterionId, value: value})
- ratedCriteria.append(criterionId)
- )
- )
- if ratings.len != challengeCriteria(space).len (
- c.badRequest("score every criterion from 1 to 10")
- return
- )
- string feedback = body.feedback.toStr().strip()
- if feedback.len > 2000 (
- c.badRequest("feedback is too long")
- return
- )
- string judge = contextString(c, "username")
- array scores = challengeArray(space, "scores")
- array nextScores = []
- for i scores.len (
- object existing = scores[i].assert(object)
- if existing.projectId.toStr() != projectId or normalizeUsername(existing.judge.toStr()) != normalizeUsername(judge) (
- nextScores.append(existing)
- )
- )
- object score = {projectId: projectId, judge: judge, ratings: ratings, feedback: feedback, edited: timestamp}
- nextScores.append(score)
- space.scores = nextScores
- space.edited = timestamp
- putSpaceDocument(space)
- c.json(200, {ok: true, score: score})
-)
-
-def handleVoteChallengeEntry(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- string projectId = c.param("project")
- if space.len == 0 or space.kind.toStr() != "challenge" or !spaceProjectIds(space).contains(projectId) (
- c.notFound("challenge entry not found")
- return
- )
- if !challengeAudienceVotingEnabled(space) or challengePhase(space) != "judging" (
- c.badRequest("community voting is not open")
- return
- )
- object project = (projectSearchDocs.findById(projectId) ?? {})
- if project.len > 0 and searchEntryOwnedByContext(project, c) (
- c.forbidden("you cannot vote for your own entry")
- return
- )
- number value = requestJSON(c).value.toNum()
- if value < 1 or value > 5 (
- c.badRequest("rating must be between 1 and 5")
- return
- )
- string username = contextString(c, "username")
- array votes = challengeArray(space, "audienceVotes")
- array nextVotes = []
- for i votes.len (
- object vote = votes[i].assert(object)
- if vote.projectId.toStr() != projectId or normalizeUsername(vote.username.toStr()) != normalizeUsername(username) (
- nextVotes.append(vote)
- )
- )
- nextVotes.append({projectId: projectId, username: username, value: value, edited: timestamp})
- space.audienceVotes = nextVotes
- putSpaceDocument(space)
- c.json(200, {ok: true, value: value})
-)
-
-def handlePublishChallengeResults(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or space.kind.toStr() != "challenge" or !canManageSpace(space, c) (
- c.forbidden("you cannot manage this challenge")
- return
- )
- if timestamp <= space.endsAt.toNum() (
- c.badRequest("submissions must close before results can be published")
- return
- )
- if challengeAudienceJudged(space) (
- if challengeArray(space, "audienceVotes").len == 0 (
- c.badRequest("at least one entry needs an audience rating")
- return
- )
- ) else if challengeArray(space, "scores").len == 0 (
- c.badRequest("at least one entry needs a judge score")
- return
- )
- space.resultsPublishedAt = timestamp
- space.edited = timestamp
- putSpaceDocument(space)
- c.json(200, {ok: true, space: spaceResponse(space, c)})
-)
-
-def handleFollowSpace(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !canViewSpace(space, c) (
- c.notFound("space not found")
- return
- )
- array followers = spaceFollowers(space)
- if !stringArrayContainsUser(followers, contextString(c, "username")) (
- followers.append(contextString(c, "username"))
- space.followers = followers
- putSpaceDocument(space)
- )
- c.json(200, {ok: true, following: true, followers: followers.len})
-)
-
-def handleUnfollowSpace(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- array followers = spaceFollowers(space)
- if stringArrayContainsUser(followers, contextString(c, "username")) (
- followers = removeUser(followers, contextString(c, "username"))
- space.followers = followers
- putSpaceDocument(space)
- )
- c.json(200, {ok: true, following: false, followers: followers.len})
-)
-
-def handleMySpaces(*serve.Context c) (
- string userKey = normalizeUsername(contextString(c, "username"))
- array memberships = spaceMemberDocs.where("userKey", "==", userKey).all()
- array stored = []
- object seen = {}
- for i memberships.len (
- object membership = memberships[i].assert(object)
- object space = (spaceDocs.findById(membership.spaceId.toStr()) ?? {})
- if space.len > 0 (
- seen[space["_id"].toStr()] = true
- stored.append(space)
- )
- )
- array allSpaces = spaceDocs.all()
- for i allSpaces.len (
- object space = allSpaces[i].assert(object)
- string spaceId = space["_id"].toStr()
- if !seen.contains(spaceId) (
- boolean include = isSpaceOwner(space, c)
- if !include and groupTagOf(space) != "" (
- include = canManageGroupContent(space, c)
- )
- if include (
- seen[spaceId] = true
- stored.append(space)
- )
- )
- )
- stored.sortBy("edited", "descending")
- array spaces = []
- for i stored.len (
- object space = stored[i].assert(object)
- spaces.append(spaceListResponse(space, c))
- )
- c.json(200, {ok: true, spaces: spaces})
-)
-
-def handleGetSpaceManagement(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- if !canManageSpace(space, c) (
- c.forbidden("you cannot manage this space")
- return
- )
- c.json(200, {ok: true, space: spaceManagementResponse(space, c)})
-)
-
-def handleInviteSpaceCurator(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- if !isSpaceOwner(space, c) (
- c.forbidden("only the owner can invite curators")
- return
- )
- string username = requestJSON(c).username.toStr().strip()
- string usernameKey = normalizeUsername(username)
- if usernameKey == "" or username.len > 20 or usernameKey == normalizeUsername(space.owner.toStr()) (
- c.badRequest("choose another user")
- return
- )
- object profile = (profileSearchDocs.findById(usernameKey) ?? {})
- if profile.len == 0 (
- c.notFound("user not found")
- return
- )
- if stringArrayContainsUser(spaceManagers(space), username) (
- c.badRequest("this user is already a curator")
- return
- )
- array invites = spaceCuratorInvites(space)
- for i invites.len (
- object existing = invites[i].assert(object)
- if normalizeUsername(existing.username.toStr()) == usernameKey (
- c.badRequest("this user already has an invitation")
- return
- )
- )
- object invite = {username: profile.username, invitedBy: contextString(c, "username"), created: timestamp}
- invites.append(invite)
- space.curatorInvites = invites
- space.edited = timestamp
- putSpaceDocument(space)
- addNotification(profile.username.toStr(), contextString(c, "username"), "space_curator_invite", {spaceId: space["_id"], spaceTitle: space.title})
- c.json(201, {ok: true, invitation: invite})
-)
-
-def handleRespondSpaceCuratorInvite(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- string username = contextString(c, "username")
- array invites = spaceCuratorInvites(space)
- array remaining = []
- boolean found = false
- for i invites.len (
- object invite = invites[i].assert(object)
- if normalizeUsername(invite.username.toStr()) == normalizeUsername(username) (
- found = true
- ) else (
- remaining.append(invite)
- )
- )
- if !found (
- c.notFound("invitation not found")
- return
- )
- boolean accepted = requestJSON(c).accepted == true
- if accepted (
- array managers = spaceManagers(space)
- if !stringArrayContainsUser(managers, username) (
- managers.append(username)
- )
- space.managers = managers
- )
- space.curatorInvites = remaining
- space.edited = timestamp
- putSpaceDocument(space)
- string notificationType = "space_curator_declined"
- if accepted (
- notificationType = "space_curator_accepted"
- )
- addNotification(space.owner.toStr(), username, notificationType, {spaceId: space["_id"], spaceTitle: space.title})
- c.json(200, {ok: true, accepted: accepted})
-)
-
-def handleRemoveSpaceCurator(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- if !isSpaceOwner(space, c) (
- c.forbidden("only the owner can remove curators")
- return
- )
- string username = c.param("username")
- array managers = spaceManagers(space)
- if stringArrayContainsUser(managers, username) (
- space.managers = removeUser(managers, username)
- space.edited = timestamp
- putSpaceDocument(space)
- addNotification(username, contextString(c, "username"), "space_curator_removed", {spaceId: space["_id"], spaceTitle: space.title})
- )
- c.json(200, {ok: true})
-)
-
-def handleCancelSpaceCuratorInvite(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- if !isSpaceOwner(space, c) (
- c.forbidden("only the owner can cancel invitations")
- return
- )
- string username = c.param("username")
- array remaining = []
- array invites = spaceCuratorInvites(space)
- boolean found = false
- for i invites.len (
- object invite = invites[i].assert(object)
- if normalizeUsername(invite.username.toStr()) != normalizeUsername(username) (
- remaining.append(invite)
- ) else (
- found = true
- )
- )
- if found (
- space.curatorInvites = remaining
- space.edited = timestamp
- putSpaceDocument(space)
- )
- c.json(200, {ok: true})
-)
-
-def handleReactSpace(*serve.Context c) (
- string lockName = "space-reactions:" ++ c.param("id")
- sync.lock(lockName)
- handleReactSpaceLocked(c)
- sync.unlock(lockName)
-)
-
-def handleReactSpaceLocked(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !canViewSpace(space, c) (
- c.notFound("space not found")
- return
- )
- string reactionType = requestJSON(c).type.toStr()
- if !isValidReaction(reactionType) (
- c.badRequest("invalid reaction")
- return
- )
- string username = contextString(c, "username")
- array likes = spaceLikes(space)
- array brokenHearts = spaceBrokenHearts(space)
- number previousLikes = likes.len
- boolean hadReaction = false
- if reactionType == "heart" (
- hadReaction = stringArrayContainsUser(likes, username)
- ) else (
- hadReaction = stringArrayContainsUser(brokenHearts, username)
- )
- likes = removeUser(likes, username)
- brokenHearts = removeUser(brokenHearts, username)
- if !hadReaction and reactionType == "heart" (
- likes.append(username)
- ) else if !hadReaction and reactionType == "brokenheart" (
- brokenHearts.append(username)
- )
- space.likes = likes
- space.brokenHearts = brokenHearts
- space.edited = timestamp
- if !putSpaceDocument(space) (
- c.internalError("could not save reaction")
- return
- )
- notifyLikeMilestones(space.owner.toStr(), space.kind.toStr(), space["_id"].toStr(), previousLikes, likes.len, "/spaces/" ++ space["_id"].toStr(), {})
- c.json(200, {ok: true, likeCount: likes.len, brokenHeartCount: brokenHearts.len, myReaction: if hadReaction then "" else reactionType})
-)
-
-def handleGetSpaceComments(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !canViewSpace(space, c) (
- c.notFound("space not found")
- return
- )
- object page = commentPage(loadComments(spaceCommentKey(space["_id"].toStr())), c)
- page.ok = true
- c.json(200, page)
-)
-
-def handleCreateSpaceComment(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !canViewSpace(space, c) (
- c.notFound("space not found")
- return
- )
- object body = requestJSON(c)
- if !body.contains("content") or body.content == null (
- c.badRequest("comment content is required")
- return
- )
- string parentId = ""
- if body.contains("parent") and body.parent != null (
- parentId = body.parent.toStr()
- )
- object created = createComment(spaceCommentKey(space["_id"].toStr()), contextString(c, "username"), contextString(c, "userId"), body.content.toStr(), parentId, "comment", 0, "")
- if !created.ok (
- c.badRequest(created.error.toStr())
- return
- )
- if space.contains("commentCount") (
- space.commentCount = space.commentCount.toNum() + 1
- ) else (
- space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len
- )
- putSpaceDocument(space)
- string commenter = contextString(c, "username")
- string parentAuthor = created.parentAuthor.toStr()
- string newCommentId = created.comment.id.toStr()
- object notificationData = {spaceId: space["_id"], spaceTitle: space.title, commentId: newCommentId, preview: created.comment.content.toStr()}
- if parentAuthor != "" and normalizeUsername(parentAuthor) != normalizeUsername(commenter) (
- addNotification(parentAuthor, commenter, "reply", notificationData)
- )
- if normalizeUsername(space.owner.toStr()) != normalizeUsername(commenter) and normalizeUsername(space.owner.toStr()) != normalizeUsername(parentAuthor) (
- addNotification(space.owner.toStr(), commenter, "space_comment", notificationData)
- )
- notifyMentions(created.comment.content.toStr(), commenter, [space.owner.toStr(), parentAuthor], notificationData)
- c.json(201, created)
-)
-
-def handleDeleteSpaceComment(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 (
- c.notFound("space not found")
- return
- )
- string moderator = space.owner.toStr()
- if canManageSpace(space, c) (
- moderator = contextString(c, "username")
- )
- object deleted = deleteComment(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), moderator, contextBool(c, "isAdmin"))
- if !deleted.ok (
- c.json(deleted.status.toInt(), {ok: false, error: deleted.error})
- return
- )
- if space.contains("commentCount") (
- space.commentCount = clampNumber(space.commentCount.toNum() - deleted.removed.toNum(), 0, space.commentCount.toNum())
- ) else (
- space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len
- )
- putSpaceDocument(space)
- c.json(200, {ok: true})
-)
-
-def handleEditSpaceComment(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !canViewSpace(space, c) (
- c.notFound("space not found")
- return
- )
- object body = requestJSON(c)
- if !body.contains("content") or body.content == null (
- c.badRequest("comment content is required")
- return
- )
- object edited = editComment(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), body.content.toStr())
- if !edited.ok (
- c.json(edited.status.toInt(), {ok: false, error: edited.error})
- return
- )
- c.json(200, edited)
-)
-
-def handlePinSpaceComment(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !canViewSpace(space, c) (
- c.notFound("space not found")
- return
- )
- string moderator = space.owner.toStr()
- if canManageSpace(space, c) (
- moderator = contextString(c, "username")
- )
- object body = requestJSON(c)
- boolean pinned = true
- if body.contains("pinned") and body.pinned != null (
- pinned = body.pinned == true
- )
- object updated = setCommentPin(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), moderator, contextBool(c, "isAdmin"), pinned)
- if !updated.ok (
- c.json(updated.status.toInt(), {ok: false, error: updated.error})
- return
- )
- updated.comment = commentResponse(updated.comment.assert(object), contextString(c, "username"))
- c.json(200, updated)
-)
-
-def handleReactSpaceComment(*serve.Context c) (
- object space = (spaceDocs.findById(c.param("id")) ?? {})
- if space.len == 0 or !canViewSpace(space, c) (
- c.notFound("space not found")
- return
- )
- reactToComment(c, spaceCommentKey(space["_id"].toStr()), "/spaces/" ++ space["_id"].toStr())
-)
-
-def handleCreatePreview(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok (
- c.notFound("project not found")
- return
- )
- object project = res.project.assert(object)
- if !canEditProject(project, c) (
- c.forbidden("not your project")
- return
- )
- if projectMustStayPrivate(project) (
- c.forbidden("remixes of paid projects cannot create shareable preview links")
- return
- )
- array previews = previewDocs.where("projectId", "==", project.id.toStr()).all()
- number activePreviews = 0
- for i previews.len (
- object existing = previews[i].assert(object)
- if existing.expiresAt.toNum() <= timestamp (
- void previewDocs.deleteOne({_id: existing["_id"]})
- ) else (
- activePreviews += 1
- )
- )
- if activePreviews >= 20 (
- c.badRequest("a project can have at most 20 active previews")
- return
- )
- object body = requestJSON(c)
- number hours = 24
- if body.contains("hours") (
- hours = clampNumber(body.hours.toNum(), 1, 168)
- )
- string key = randomString(24)
- string previewId = project.id.toStr() ++ ":" ++ key
- object preview = {_id: previewId, projectId: project.id, key: key, owner: contextString(c, "username"), ownerId: contextString(c, "userId"), created: timestamp, expiresAt: timestamp + (hours * 3600000)}
- void previewDocs.insertOne(preview)
- c.json(201, {ok: true, key: key, expiresAt: preview.expiresAt})
-)
-
-def handleListReleases(*serve.Context c) (
- c.setHeader("Cache-Control", "private, no-store")
- c.setHeader("CDN-Cache-Control", "private, no-store")
- auto res = loadProject(c.param("id"))
- if !res.ok or !canViewProject(res.project.assert(object), c) (
- c.notFound("project not found")
- return
- )
- object project = res.project.assert(object)
- boolean canEdit = canEditProject(project, c)
- ensureViewKey(project)
- object jsonAccess = projectJsonAccess(project, c)
- array stored = releaseDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(100).all()
- array releases = []
- for i stored.len (
- object release = stored[i].assert(object)
- if release.channel.toStr() != "checkpoint" or canEdit (
- releases.append(publicReleaseRecord(release, jsonAccess))
- )
- )
- c.json(200, {ok: true, releases: releases})
-)
-
-def handleCreateRelease(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok (
- c.notFound("project not found")
- return
- )
- object project = res.project.assert(object)
- if !canEditProject(project, c) (
- c.forbidden("not your project")
- return
- )
- object body = requestJSON(c)
- string version = body.version.toStr().strip()
- string releaseChannel = body.channel.toStr().toLower().strip()
- if version == "" or version.len > 50 (
- c.badRequest("invalid version")
- return
- )
- if releaseChannel != "stable" and releaseChannel != "beta" and releaseChannel != "development" and releaseChannel != "checkpoint" (
- c.badRequest("invalid release channel")
- return
- )
- if releaseChannel == "checkpoint" and mistwarpBenefitsForProject(project).historyCheckpoints != true (
- c.forbidden("named history checkpoints require Rotur Lite, Plus, or Pro")
- return
- )
- number releaseLimit = mistwarpBenefitsForProject(project).maxProjectReleases.toNum()
- if releaseDocs.where("projectId", "==", project.id.toStr()).limit(releaseLimit.toInt()).all().len >= releaseLimit (
- c.badRequest("this project has reached its release limit")
- return
- )
- string notes = ""
- if body.contains("notes") (
- notes = body.notes.toStr().strip()
- )
- if notes.len > 10000 (
- c.badRequest("release notes are too long")
- return
- )
- string releaseId = project.id.toStr() ++ ":" ++ randomString(12)
- string commit = ""
- if body.contains("commit") (
- commit = body.commit.toStr()
- )
- object snapshot = ensureProjectJsonSnapshot(project)
- if !snapshot.ok (
- c.internalError("could not snapshot the current project")
- return
- )
- project = snapshot.project.assert(object)
- string jsonUrl = appURL ++ "/v1/projects/" ++ project.id.toStr() ++ "/releases/" ++ url.escape(releaseId) ++ "/project.json"
- // jsonBytes is the snapshot's size, which counts against the owner's
- // storage for as long as the release keeps it.
- object release = {_id: releaseId, projectId: project.id, version: version, channel: releaseChannel, notes: notes, commit: commit, jsonKey: project.jsonKey, jsonBytes: (project.storedJsonBytes ?? 0).toNum(), jsonGzip: true, jsonUrl: jsonUrl, assetsBase: assetsBaseUrl(), created: timestamp, creator: contextString(c, "username"), creatorId: contextString(c, "userId")}
- void releaseDocs.insertOne(release)
- storageSettleProject(project)
- if releaseChannel == "stable" (
- project.publicRelease = releaseId
- saveProject(project)
- )
- if releaseChannel != "checkpoint" (
- addActivity(contextString(c, "username"), "release", {projectId: project.id, projectTitle: project.title, version: version})
- )
- c.json(201, {ok: true, release: publicReleaseRecord(release, projectJsonAccess(project, c))})
-)
-
-def handleGetReleaseProjectJson(*serve.Context c) (
- object release = (releaseDocs.findById(c.param("release")) ?? {})
- if release.len == 0 or release.projectId.toStr() != c.param("id") (
- c.notFound("release not found")
- return
- )
- auto res = loadProject(c.param("id"))
- if !res.ok or !canViewProjectJson(res.project.assert(object), c) (
- c.notFound("release not found")
- return
- )
- string releaseEtag = "\"" ++ release["_id"].toStr() ++ "\""
- c.setHeader("ETag", releaseEtag)
- c.setHeader("Cache-Control", "no-cache")
- if (c.header("If-None-Match") ?? "").contains(releaseEtag) (
- c.status(304)
- return
- )
- if release.contains("jsonKey") and release.jsonKey.toStr() != "" (
- serveProjectJsonStorageKey(c, release.jsonKey.toStr(), release.contains("jsonGzip") and release.jsonGzip == true)
- return
- )
- string releasePath = releasesDir ++ release["_id"].toStr().replace(":", "-") ++ ".json"
- if !fs.exists(releasePath) (
- c.notFound("release snapshot not found")
- return
- )
- c.setHeader("Content-Type", "application/json")
- c.text(200, fs.readFile(releasePath).unwrapOr(""))
-)
-
-def handleDeleteRelease(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok (
- c.notFound("project not found")
- return
- )
- object project = res.project.assert(object)
- if !canEditProject(project, c) (
- c.forbidden("not your project")
- return
- )
- object release = (releaseDocs.findById(c.param("release")) ?? {})
- if release.len == 0 or release.projectId.toStr() != project.id.toStr() (
- c.notFound("release not found")
- return
- )
- string jsonKey = (release.jsonKey ?? "").toStr()
- void releaseDocs.deleteOne({_id: release["_id"]})
- void fs.remove(releasesDir ++ release["_id"].toStr().replace(":", "-") ++ ".json")
- if jsonKey != "" (
- removeProjectStorageKeyIfOrphaned(jsonKey, "", false)
- )
- storageSettleProject(project)
- if project.publicRelease.toStr() == release["_id"].toStr() (
- project.publicRelease = ""
- saveProject(project)
- )
- c.json(200, {ok: true})
-)
-
-def handleCreateContribution(*serve.Context c) (
- auto parentResult = loadProject(c.param("id"))
- object body = requestJSON(c)
- auto remixResult = loadProject(body.remixProjectId.toStr())
- if !parentResult.ok or !remixResult.ok (
- c.notFound("project not found")
- return
- )
- object parent = parentResult.project.assert(object)
- object remix = remixResult.project.assert(object)
- string title = body.title.toStr().strip()
- string description = ""
- if body.contains("body") (
- description = body.body.toStr()
- )
- string bountyId = ""
- if body.contains("bountyId") and body.bountyId != null (
- bountyId = body.bountyId.toStr().strip()
- )
- object created = createNativePull(c, parent, remix, title, description, bountyId)
- if !created.ok (
- c.json(created.status.toInt(), {ok: false, error: created.error})
- return
- )
- c.json(201, created)
-)
-
-def playtimeIdentity(string username) string (
- string userId = userIdForUsername(username)
- if userId != "" (
- return userId
- )
- return normalizeUsername(username)
-)
-
-def beginPlaytimeSession(string projectId, string username, string sessionId) boolean (
- string userId = userIdForUsername(username)
- string identityKey = playtimeIdentity(username)
- object session = (playtimeSessionDocs.findById(sessionId) ?? {})
- if session.len == 0 or session.projectId.toStr() != projectId or timestamp - session.lastSeen.toNum() > 120000 (
- putDocument(playtimeSessionDocs, sessionId, {
- _id: sessionId,
- projectId: projectId,
- username: username,
- userId: userId,
- identityKey: identityKey,
- lastCredit: timestamp,
- lastSeen: timestamp
- })
- )
- return true
-)
-
-def touchRecentProjectActivity(string projectId, string username) boolean (
- string userId = userIdForUsername(username)
- string identityKey = userId
- if identityKey == "" (
- identityKey = normalizeUsername(username)
- )
- string playtimeId = projectId ++ ":" ++ identityKey
- string playtimeLock = "playtime:" ++ playtimeId
- sync.lock(playtimeLock)
- object playtime = (playtimeDocs.findById(playtimeId) ?? {})
- if playtime.len == 0 (
- playtime = {_id: playtimeId, projectId: projectId, username: username, userId: userId, duration: 0, updated: timestamp}
- ) else (
- playtime.username = username
- playtime.userId = userId
- playtime.updated = timestamp
- )
- playtime.projectId = projectId
- boolean saved = putDocument(playtimeDocs, playtimeId, playtime)
- sync.unlock(playtimeLock)
- return saved
-)
-
-def creditPlaytimeSession(string projectId, string username, string sessionId, boolean finished) number (
- string userId = userIdForUsername(username)
- object session = (playtimeSessionDocs.findById(sessionId) ?? {})
- if session.len == 0 or session.projectId.toStr() != projectId (
- return 0
- )
- number credited = clampNumber(timestamp - session.lastCredit.toNum(), 0, 60000)
- if credited > 0 (
- string identityKey = session.identityKey.assertElse(string, playtimeIdentity(username))
- string playtimeId = projectId ++ ":" ++ identityKey
- string playtimeLock = "playtime:" ++ playtimeId
- sync.lock(playtimeLock)
- object playtime = (playtimeDocs.findById(playtimeId) ?? {})
- if playtime.len == 0 (
- playtime = {_id: playtimeId, projectId: projectId, username: username, userId: userId, duration: 0, updated: timestamp}
- )
- playtime.duration = playtime.duration.toNum() + credited
- playtime.updated = timestamp
- playtime.projectId = projectId
- putDocument(playtimeDocs, playtimeId, playtime)
- sync.unlock(playtimeLock)
- )
- if finished (
- void playtimeSessionDocs.deleteOne({_id: sessionId})
- ) else (
- session.lastCredit = timestamp
- session.lastSeen = timestamp
- putDocument(playtimeSessionDocs, sessionId, session)
- )
- return credited
-)
-
-def recordProjectDiagnostic(string projectId, boolean authenticated, string username, string sessionId, object body) object (
- string eventType = body.type.toStr().toLower().strip()
- if eventType != "load" and eventType != "start" and eventType != "crash" and eventType != "exit" and eventType != "playtime" and eventType != "playtime_start" and eventType != "playtime_ping" and eventType != "playtime_finish" (
- return {ok: false, error: "invalid diagnostic type"}
- )
- string diagnosticId = projectId ++ ":" ++ randomString(16)
- number duration = 0
- number loadMs = 0
- string errorText = ""
- string device = ""
- if body.contains("duration") (
- duration = clampNumber(body.duration.toNum(), 0, 86400000)
- )
- if body.contains("loadMs") (
- loadMs = clampNumber(body.loadMs.toNum(), 0, 600000)
- )
- if body.contains("error") (
- errorText = body.error.toStr().trim(1, 500)
- )
- if body.contains("device") (
- device = body.device.toStr().trim(1, 40)
- )
- if eventType == "playtime_start" or eventType == "playtime_ping" or eventType == "playtime_finish" or eventType == "playtime" (
- if !authenticated (
- return {ok: true}
- )
- if eventType == "playtime_start" (
- beginPlaytimeSession(projectId, username, sessionId)
- ) else if eventType == "playtime_finish" (
- creditPlaytimeSession(projectId, username, sessionId, true)
- ) else (
- creditPlaytimeSession(projectId, username, sessionId, false)
- )
- return {ok: true}
- )
- if eventType == "start" and authenticated (
- beginPlaytimeSession(projectId, username, sessionId)
- )
- if eventType == "load" and authenticated (
- touchRecentProjectActivity(projectId, username)
- )
- object item = {_id: diagnosticId, projectId: projectId, type: eventType, duration: duration, loadMs: loadMs, error: errorText, device: device, created: timestamp}
- string diagnosticLock = "project-diagnostics-" ++ projectId
- sync.lock(diagnosticLock)
- void diagnosticDocs.insertOne(item)
- array storedDiagnostics = diagnosticDocs.where("projectId", "==", projectId).sort("created", "desc").limit(550).all()
- for i storedDiagnostics.len (
- if i > 500 (
- void diagnosticDocs.deleteOne({_id: storedDiagnostics[i]["_id"]})
- )
- )
- sync.unlock(diagnosticLock)
- return {ok: true}
-)
-
-def handleRecordDiagnostic(*serve.Context c) (
- auto projectResult = loadProject(c.param("id"))
- if !projectResult.ok or !projectIsViewable(projectResult.project.assert(object)) (
- c.notFound("project not found")
- return
- )
- string sessionId = playtimeIdentity(contextString(c, "username"))
- object res = recordProjectDiagnostic(
- c.param("id"),
- contextBool(c, "authenticated"),
- contextString(c, "username"),
- sessionId,
- requestJSON(c)
- )
- if !res.ok (
- c.badRequest(res.error.toStr())
- return
- )
- c.json(201, {ok: true})
-)
-
-def handleGetDiagnostics(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok or !canEditProject(res.project.assert(object), c) (
- c.forbidden("not your project")
- return
- )
- array items = diagnosticDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(500).all()
- object counts = {load: 0, start: 0, crash: 0, exit: 0}
- number totalLoadMs = 0
- number loadSamples = 0
- for i items.len (
- object item = items[i].assert(object)
- string kind = item.type.toStr()
- counts[kind] = counts[kind].toNum() + 1
- if item.loadMs.toNum() > 0 (
- totalLoadMs += item.loadMs.toNum()
- loadSamples += 1
- )
- )
- number averageLoadMs = 0
- if loadSamples > 0 (
- averageLoadMs = totalLoadMs / loadSamples
- )
- c.json(200, {ok: true, counts: counts, averageLoadMs: averageLoadMs, recent: paginate(items, 0, 50)})
-)
-
-def handleCreateProjectFeedback(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok or !canViewProject(res.project.assert(object), c) (
- c.notFound("project not found")
- return
- )
- object project = res.project.assert(object)
- if feedbackDocs.where("projectId", "==", project.id.toStr()).limit(200).all().len >= 200 (
- c.badRequest("this project has reached its feedback limit")
- return
- )
- object body = requestJSON(c)
- string feedbackType = body.type.toStr().toLower().strip()
- if feedbackType != "bug" and feedbackType != "idea" and feedbackType != "confusing" and feedbackType != "other" (
- c.badRequest("invalid feedback type")
- return
- )
- string message = body.message.toStr().strip()
- if message == "" or message.len > 2000 (
- c.badRequest("feedback must be between 1 and 2000 characters")
- return
- )
- string feedbackId = project.id.toStr() ++ ":" ++ randomString(16)
- object feedback = {_id: feedbackId, projectId: project.id, type: feedbackType, message: message, status: "open", author: contextString(c, "username"), authorId: contextString(c, "userId"), created: timestamp, edited: timestamp}
- void feedbackDocs.insertOne(feedback)
- addNotification(project.owner.toStr(), contextString(c, "username"), "project_feedback", {projectId: project.id, projectTitle: project.title, feedbackType: feedbackType})
- c.json(201, {ok: true})
-)
-
-def handleGetProjectFeedback(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok or !canEditProject(res.project.assert(object), c) (
- c.forbidden("not your project")
- return
- )
- array feedback = feedbackDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(200).all()
- c.json(200, {ok: true, feedback: feedback})
-)
-
-def handleUpdateProjectFeedback(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok or !canEditProject(res.project.assert(object), c) (
- c.forbidden("not your project")
- return
- )
- object feedback = (feedbackDocs.findById(c.param("feedback")) ?? {})
- if feedback.len == 0 or feedback.projectId.toStr() != c.param("id") (
- c.notFound("feedback not found")
- return
- )
- string status = requestJSON(c).status.toStr().toLower()
- if status != "open" and status != "working" and status != "done" and status != "dismissed" (
- c.badRequest("invalid feedback status")
- return
- )
- feedback.status = status
- feedback.edited = timestamp
- putDocument(feedbackDocs, feedback["_id"].toStr(), feedback)
- c.json(200, {ok: true, feedback: feedback})
-)
-
-def handleListProjectReviews(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok or !canViewProject(res.project.assert(object), c) (
- c.notFound("project not found")
- return
- )
- array reviews = reviewsVisibleTo(reviewDocs.where("projectId", "==", c.param("id")).sort("edited", "desc").limit(200).all(), c)
- number ratingTotal = 0
- object myReview = {}
- for i reviews.len (
- object review = reviews[i].assert(object)
- ratingTotal += review.rating.toNum()
- review.playtimeMs = authorPublicPlaytime(c.param("id"), review.author.toStr())
- string authorId = ""
- if review.contains("authorId") and review.authorId != null (
- authorId = review.authorId.toStr()
- )
- if identityMatches(authorId, review.author.toStr(), contextString(c, "userId"), contextString(c, "username")) (
- myReview = review
- )
- reviews[i] = review
- )
- number average = 0
- if reviews.len > 0 (
- average = ratingTotal / reviews.len
- )
- c.json(200, {ok: true, reviews: reviews, count: reviews.len, average: average, myReview: myReview})
-)
-
-def handleListUserReviews(*serve.Context c) (
- string name = normalizeUsername(c.param("name"))
- string requestedId = userIdForUsername(name)
- number limit = clampNumber(c.queryInt("limit", 20), 1, 50)
- array allReviews = reviewDocs.sort("edited", "desc").limit(1000).all()
- array reviews = []
- for i allReviews.len (
- object review = allReviews[i].assert(object)
- string authorId = ""
- if review.contains("authorId") and review.authorId != null (
- authorId = review.authorId.toStr()
- )
- if identityMatches(authorId, review.author.toStr(), requestedId, name) and reviews.len < limit and viewerSeesShadowBanned(review.author.toStr(), authorId, c) (
- auto res = loadProject(review.projectId.toStr())
- if res.ok and canViewProject(res.project.assert(object), c) (
- object project = res.project.assert(object)
- review.projectTitle = project.title
- review.projectOwner = project.owner
- review.playtimeMs = authorPublicPlaytime(review.projectId.toStr(), review.author.toStr())
- reviews.append(review)
- )
- )
- )
- c.json(200, {ok: true, reviews: reviews})
-)
-
-def handleSaveProjectReview(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok or !canViewProject(res.project.assert(object), c) (
- c.notFound("project not found")
- return
- )
- object project = res.project.assert(object)
- if projectRole(project, contextString(c, "username")) == "owner" (
- c.badRequest("you cannot review your own project")
- return
- )
- object body = requestJSON(c)
- number rating = body.rating.toInt()
- if rating < 1 or rating > 5 (
- c.badRequest("rating must be between 1 and 5")
- return
- )
- string message = ""
- if body.contains("message") (
- message = body.message.toStr().strip()
- )
- if message.len > 2000 (
- c.badRequest("review is too long")
- return
- )
- string reviewerId = contextString(c, "userId")
- if reviewerId == "" (
- reviewerId = normalizeUsername(contextString(c, "username"))
- )
- string reviewId = project.id.toStr() ++ ":" ++ reviewerId
- object existing = (reviewDocs.findById(reviewId) ?? {})
- number created = timestamp
- if existing.len > 0 (
- created = existing.created.toNum()
- )
- object review = {_id: reviewId, projectId: project.id, author: contextString(c, "username"), authorId: contextString(c, "userId"), authorKey: normalizeUsername(contextString(c, "username")), rating: rating, message: message, created: created, edited: timestamp}
- putDocument(reviewDocs, reviewId, review)
- if existing.len == 0 (
- addNotification(project.owner.toStr(), contextString(c, "username"), "project_review", {projectId: project.id, projectTitle: project.title, rating: rating})
- )
- review.playtimeMs = authorPublicPlaytime(project.id.toStr(), review.author.toStr())
- c.json(200, {ok: true, review: review})
-)
-
-def handleDeleteProjectReview(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok or !canViewProject(res.project.assert(object), c) (
- c.notFound("project not found")
- return
- )
- string reviewerId = contextString(c, "userId")
- if reviewerId == "" (
- reviewerId = normalizeUsername(contextString(c, "username"))
- )
- string reviewId = c.param("id") ++ ":" ++ reviewerId
- void reviewDocs.deleteOne({_id: reviewId})
- c.json(200, {ok: true})
-)
-
-def handleListIdeas(*serve.Context c) (
- array ideas = ideaDocs.sort("created", "desc").all()
- string username = contextString(c, "username")
- array developmentFeed = developmentPulls()
- for i ideas.len (
- object idea = ideas[i].assert(object)
- boolean needsSave = false
- if !idea.contains("source") (
- idea.source = "community"
- if normalizeUsername(idea.author.toStr()) == "mist" (
- idea.source = "mistwarp"
- )
- needsSave = true
- )
- if !idea.contains("interested") (
- idea.interested = idea.source.toStr() == "mistwarp"
- needsSave = true
- )
- if !idea.contains("kind") (
- idea.kind = "idea"
- needsSave = true
- )
- if !idea.contains("commentCount") (
- idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len
- needsSave = true
- )
- if needsSave (
- putDocument(ideaDocs, idea["_id"].toStr(), idea)
- )
- array likes = []
- array dislikes = []
- if idea.contains("likes") (
- likes = idea["likes"].assertElse(array, [])
- ) else if idea.contains("voters") (
- likes = idea["voters"].assertElse(array, [])
- )
- if idea.contains("dislikes") (
- dislikes = idea["dislikes"].assertElse(array, [])
- )
- idea.likesCount = likes.len
- idea.dislikesCount = dislikes.len
- idea.score = likes.len - dislikes.len
- idea.myVote = ""
- if stringArrayContainsUser(likes, username) (
- idea.myVote = "like"
- ) else if stringArrayContainsUser(dislikes, username) (
- idea.myVote = "dislike"
- )
- idea.likes = []
- idea.dislikes = []
- idea.voters = []
- idea.pullRequests = resolveIdeaPulls(idea, developmentFeed)
- ideas[i] = idea
- )
- ideas.sortBy("score", "descending")
- c.json(200, {ok: true, ideas: ideas})
-)
-
-def handleCreateIdea(*serve.Context c) (
- object body = requestJSON(c)
- string title = body.title.toStr().strip()
- string description = body.description.toStr().strip()
- if title == "" or title.len > 120 or description == "" or description.len > 3000 (
- c.badRequest("add a title and a description")
- return
- )
- string category = "Other"
- if body.contains("category") (
- category = body.category.toStr().trim(1, 40)
- )
- string kind = "idea"
- if body.contains("kind") (
- kind = body.kind.toStr().toLower()
- )
- if kind != "idea" and kind != "bug" (
- c.badRequest("invalid roadmap entry type")
- return
- )
- string ideaId = "i" ++ randomString(14)
- string source = "community"
- if contextBool(c, "isAdmin") (
- source = "mistwarp"
- )
- object idea = {_id: ideaId, title: title, description: description, category: category, kind: kind, author: contextString(c, "username"), authorId: contextString(c, "userId"), source: source, interested: source == "mistwarp", status: "open", pulls: [], likes: [contextString(c, "username")], dislikes: [], votes: 1, commentCount: 0, created: timestamp, edited: timestamp}
- void ideaDocs.insertOne(idea)
- idea.myVote = "like"
- idea.likesCount = 1
- idea.dislikesCount = 0
- idea.score = 1
- idea.commentCount = 0
- idea.likes = []
- idea.dislikes = []
- idea.pullRequests = []
- c.json(201, {ok: true, idea: idea})
-)
-
-def handleVoteIdea(*serve.Context c) (
- string lockName = "idea-reactions:" ++ c.param("id")
- sync.lock(lockName)
- handleVoteIdeaLocked(c)
- sync.unlock(lockName)
-)
-
-def handleVoteIdeaLocked(*serve.Context c) (
- object idea = (ideaDocs.findById(c.param("id")) ?? {})
- if idea.len == 0 (
- c.notFound("idea not found")
- return
- )
- string username = contextString(c, "username")
- string vote = requestJSON(c).vote.toStr().toLower()
- if vote != "like" and vote != "dislike" (
- c.badRequest("vote must be like or dislike")
- return
- )
- array likes = []
- array dislikes = []
- if idea.contains("likes") (
- likes = idea["likes"].assertElse(array, [])
- ) else if idea.contains("voters") (
- likes = idea["voters"].assertElse(array, [])
- )
- if idea.contains("dislikes") (
- dislikes = idea["dislikes"].assertElse(array, [])
- )
- number previousLikes = likes.len
- boolean hadVote = false
- if vote == "like" (
- hadVote = stringArrayContainsUser(likes, username)
- ) else (
- hadVote = stringArrayContainsUser(dislikes, username)
- )
- likes = removeUser(likes, username)
- dislikes = removeUser(dislikes, username)
- if !hadVote and vote == "like" (
- likes.append(username)
- ) else if !hadVote and vote == "dislike" (
- dislikes.append(username)
- )
- idea.likes = likes
- idea.dislikes = dislikes
- idea.votes = likes.len - dislikes.len
- idea.edited = timestamp
- if !putDocument(ideaDocs, idea["_id"].toStr(), idea) (
- c.internalError("could not save reaction")
- return
- )
- notifyLikeMilestones(idea.author.toStr(), "roadmap post", idea["_id"].toStr(), previousLikes, likes.len, "/roadmap#idea-" ++ idea["_id"].toStr(), {})
- string myVote = ""
- if !hadVote (
- myVote = vote
- )
- c.json(200, {ok: true, myVote: myVote, likesCount: likes.len, dislikesCount: dislikes.len, score: likes.len - dislikes.len})
-)
-
-def handleGetIdeaComments(*serve.Context c) (
- object idea = (ideaDocs.findById(c.param("id")) ?? {})
- if idea.len == 0 (
- c.notFound("idea not found")
- return
- )
- object page = commentPage(loadComments("roadmap-" ++ idea["_id"].toStr()), c)
- page.ok = true
- c.json(200, page)
-)
-
-def handleCreateIdeaComment(*serve.Context c) (
- object idea = (ideaDocs.findById(c.param("id")) ?? {})
- if idea.len == 0 (
- c.notFound("idea not found")
- return
- )
- object body = requestJSON(c)
- if !body.contains("content") or body.content == null (
- c.badRequest("comment content is required")
- return
- )
- string parentId = ""
- if body.contains("parent") and body.parent != null (
- parentId = body.parent.toStr()
- )
- object created = createComment("roadmap-" ++ idea["_id"].toStr(), contextString(c, "username"), contextString(c, "userId"), body.content.toStr(), parentId, "comment", 0, "")
- if !created.ok (
- c.badRequest(created.error.toStr())
- return
- )
- if idea.contains("commentCount") (
- idea.commentCount = idea.commentCount.toNum() + 1
- ) else (
- idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len
- )
- putDocument(ideaDocs, idea["_id"].toStr(), idea)
- string commenter = contextString(c, "username")
- string parentAuthor = created.parentAuthor.toStr()
- string preview = created.comment.content.toStr()
- if parentAuthor != "" and normalizeUsername(parentAuthor) != normalizeUsername(commenter) (
- addNotification(parentAuthor, commenter, "roadmap_comment", {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview})
- )
- if normalizeUsername(idea.author.toStr()) != normalizeUsername(parentAuthor) (
- addNotification(idea.author.toStr(), commenter, "roadmap_comment", {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview})
- )
- notifyMentions(created.comment.content.toStr(), commenter, [idea.author.toStr(), parentAuthor], {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview})
- c.json(201, created)
-)
-
-def handleDeleteIdeaComment(*serve.Context c) (
- object idea = (ideaDocs.findById(c.param("id")) ?? {})
- if idea.len == 0 (
- c.notFound("idea not found")
- return
- )
- object deleted = deleteComment("roadmap-" ++ idea["_id"].toStr(), c.param("cid"), contextString(c, "username"), idea.author.toStr(), contextBool(c, "isAdmin"))
- if !deleted.ok (
- c.json(deleted.status.toInt(), {ok: false, error: deleted.error})
- return
- )
- if idea.contains("commentCount") (
- idea.commentCount = clampNumber(
- idea.commentCount.toNum() - deleted.removed.toNum(),
- 0,
- idea.commentCount.toNum()
- )
- ) else (
- idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len
- )
- putDocument(ideaDocs, idea["_id"].toStr(), idea)
- c.json(200, {ok: true})
-)
-
-def handleEditIdeaComment(*serve.Context c) (
- object idea = (ideaDocs.findById(c.param("id")) ?? {})
- if idea.len == 0 (
- c.notFound("idea not found")
- return
- )
- object body = requestJSON(c)
- if !body.contains("content") or body.content == null (
- c.badRequest("comment content is required")
- return
- )
- object edited = editComment("roadmap-" ++ idea["_id"].toStr(), c.param("cid"), contextString(c, "username"), body.content.toStr())
- if !edited.ok (
- c.json(edited.status.toInt(), {ok: false, error: edited.error})
- return
- )
- c.json(200, edited)
-)
-
-def handleReactIdeaComment(*serve.Context c) (
- object idea = (ideaDocs.findById(c.param("id")) ?? {})
- if idea.len == 0 (
- c.notFound("idea not found")
- return
- )
- reactToComment(c, "roadmap-" ++ idea["_id"].toStr(), "/roadmap?idea=" ++ idea["_id"].toStr())
-)
-
-def handleUpdateIdea(*serve.Context c) (
- object idea = (ideaDocs.findById(c.param("id")) ?? {})
- if idea.len == 0 (
- c.notFound("idea not found")
- return
- )
- object body = requestJSON(c)
- if body.contains("status") (
- string status = body.status.toStr().toLower()
- if status == "open" or status == "planned" or status == "building" or status == "shipped" or status == "declined" (
- idea.status = status
- )
- )
- if body.contains("interested") (
- idea.interested = body.interested == true
- )
- if body.contains("pulls") and typeof(body.pulls) == "array" (
- idea.pulls = normalizePullLinks(body.pulls.assert(array))
- )
- idea.edited = timestamp
- putDocument(ideaDocs, idea["_id"].toStr(), idea)
- idea.pullRequests = resolveIdeaPulls(idea, developmentPulls())
- c.json(200, {ok: true, idea: idea})
-)
-
-def xmlEscape(string value) string (
- return value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
-)
-
-def handleProjectCard(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok or !projectIsViewable(res.project.assert(object)) (
- c.notFound("project not found")
- return
- )
- object project = res.project.assert(object)
- string title = xmlEscape(project.title.toStr()).trim(1, 58)
- string owner = xmlEscape(project.owner.toStr()).trim(1, 30)
- string thumbnail = xmlEscape(thumbUrlFor(project))
- number hearts = project.loves.assert(array).len
- string svg = ""
- c.setHeader("Cache-Control", "public, max-age=300")
- c.send(200, "image/svg+xml; charset=utf-8", storagefs.Bytes(svg))
-)
-
-def shareCardPath(object project) string (
- return cardsDir ++ project.id.toStr() ++ "-" ++ project.edited.toStr() ++ ".png"
-)
-
-def shareCardVisible(object project) boolean (
- return project.shared == true and projectIsViewable(project) and !projectHiddenByModerator(project)
-)
-
-def ensureShareCard(object project) string (
- string path = shareCardPath(project)
- if fs.exists(path) (
- return ""
- )
- return sharecard.Render(thumbnailFileFor(project), project.title.toStr(), project.owner.toStr(), path)
-)
-
-def warmShareCard(string id) (
- auto res = loadProject(id)
- if res.ok and shareCardVisible(res.project.assert(object)) (
- void ensureShareCard(res.project.assert(object))
- )
-)
-
-def handleProjectSharePng(*serve.Context c) (
- auto res = loadProject(c.param("id"))
- if !res.ok or !shareCardVisible(res.project.assert(object)) (
- c.notFound("project not found")
- return
- )
- object project = res.project.assert(object)
- if ensureShareCard(project) != "" (
- string cover = thumbUrlFor(project)
- c.setHeader("Location", if cover == "" then editorOrigin ++ "/images/apple-touch-icon.png" else cover)
- c.setHeader("Cache-Control", "public, max-age=300")
- c.status(302)
- return
- )
- boolean current = c.query("v") == project.edited.toStr()
- string etag = "\"" ++ project.id.toStr() ++ "-" ++ project.edited.toStr() ++ "\""
- c.setHeader("ETag", etag)
- c.setHeader("Cache-Control", if current then "public, max-age=31536000, immutable" else "public, max-age=3600")
- if (c.header("If-None-Match") ?? "") == etag (
- c.status(304)
- return
- )
- c.setHeader("Content-Type", "image/png")
- c.setHeader("X-Content-Type-Options", "nosniff")
- c.file(shareCardPath(project))
-)
diff --git a/src/community/community-user-records.osl b/src/community/community-user-records.osl
new file mode 100644
index 0000000..474b9d9
--- /dev/null
+++ b/src/community/community-user-records.osl
@@ -0,0 +1,312 @@
+export {
+ purgeUserCommunityRecords,
+ purgeUserFromSpaces
+}
+
+def purgeUserFromSpaces(string username) boolean (
+ string key = normalizeUsername(username)
+ array spaces = spaceDocs.all()
+ for i spaces.len (
+ object space = spaces[i].assert(object)
+ string spaceId = space["_id"].toStr()
+ if normalizeUsername(space.owner.toStr()) == key (
+ void spaceDocs.deleteOne({_id: space["_id"]})
+ deleteSpaceMemberships(spaceId)
+ void fs.remove(commentsDir ++ spaceCommentKey(spaceId) ++ ".json")
+ void fs.remove(commentsDir ++ spaceCommentKey(spaceId) ++ ".jsonl")
+ void fs.remove(spaceThumbnailPath(spaceId))
+ ) else (
+ boolean changed = spaceRelatedUserKeys(space).contains(key)
+ array likes = spaceLikes(space)
+ array broken = spaceBrokenHearts(space)
+ if stringArrayContainsUser(likes, key) or stringArrayContainsUser(broken, key) (
+ changed = true
+ )
+ space.managers = removeUser(spaceManagers(space), key)
+ space.followers = removeUser(space.followers.assertElse(array, []), key)
+ space.likes = removeUser(likes, key)
+ space.brokenHearts = removeUser(broken, key)
+ array curatorInvites = []
+ array storedCuratorInvites = spaceCuratorInvites(space)
+ for j storedCuratorInvites.len (
+ object invite = storedCuratorInvites[j].assert(object)
+ if normalizeUsername(invite.username.toStr()) != key (
+ curatorInvites.append(invite)
+ )
+ )
+ space.curatorInvites = curatorInvites
+ if space.kind.toStr() == "challenge" (
+ space.participants = removeUser(challengeArray(space, "participants"), key)
+ space.judges = removeUser(challengeArray(space, "judges"), key)
+ array judgeInvites = []
+ array storedJudgeInvites = challengeArray(space, "judgeInvites")
+ for j storedJudgeInvites.len (
+ object invite = storedJudgeInvites[j].assert(object)
+ if normalizeUsername(invite.username.toStr()) != key (
+ judgeInvites.append(invite)
+ )
+ )
+ space.judgeInvites = judgeInvites
+ array scores = []
+ array storedScores = challengeArray(space, "scores")
+ for j storedScores.len (
+ object score = storedScores[j].assert(object)
+ if normalizeUsername(score.judge.toStr()) != key (
+ scores.append(score)
+ ) else (
+ changed = true
+ )
+ )
+ space.scores = scores
+ array votes = []
+ array storedVotes = challengeArray(space, "audienceVotes")
+ for j storedVotes.len (
+ object vote = storedVotes[j].assert(object)
+ if normalizeUsername(vote.username.toStr()) != key (
+ votes.append(vote)
+ ) else (
+ changed = true
+ )
+ )
+ space.audienceVotes = votes
+ )
+ if changed (
+ space.edited = timestamp
+ putSpaceDocument(space)
+ )
+ )
+ )
+ return true
+)
+
+def purgeUserCommunityRecords(string username, string userId) boolean (
+ string key = normalizeUsername(username)
+ deleteDocumentsByField(reviewDocs, "authorKey", key)
+ deleteDocumentsByField(feedbackDocs, "author", username)
+ deleteDocumentsByField(playtimeDocs, "username", username)
+ if userId != "" (
+ deleteDocumentsByField(playtimeDocs, "userId", userId)
+ deleteDocumentsByField(feedbackDocs, "authorId", userId)
+ )
+ void playtimeSessionDocs.deleteOne({_id: key})
+
+ array releases = releaseDocs.where("creator", "==", username).all()
+ for i releases.len (
+ object release = releases[i].assert(object)
+ release.creator = "Deleted User"
+ putDocument(releaseDocs, release["_id"].toStr(), release)
+ )
+
+ array ideas = ideaDocs.all()
+ for i ideas.len (
+ object idea = ideas[i].assert(object)
+ string ideaId = idea["_id"].toStr()
+ if normalizeUsername(idea.author.toStr()) == key (
+ void ideaDocs.deleteOne({_id: idea["_id"]})
+ void fs.remove(commentsDir ++ "roadmap-" ++ ideaId ++ ".json")
+ void fs.remove(commentsDir ++ "roadmap-" ++ ideaId ++ ".jsonl")
+ ) else (
+ array likes = []
+ if idea.contains("likes") (
+ likes = idea["likes"].assertElse(array, [])
+ ) else if idea.contains("voters") (
+ likes = idea["voters"].assertElse(array, [])
+ )
+ array dislikes = []
+ if idea.contains("dislikes") (
+ dislikes = idea["dislikes"].assertElse(array, [])
+ )
+ array nextLikes = removeUser(likes, key)
+ array nextDislikes = removeUser(dislikes, key)
+ if nextLikes.len != likes.len or nextDislikes.len != dislikes.len (
+ idea.likes = nextLikes
+ idea.dislikes = nextDislikes
+ idea.voters = []
+ idea.votes = nextLikes.len - nextDislikes.len
+ putDocument(ideaDocs, ideaId, idea)
+ )
+ )
+ )
+ return true
+)
+
+def renameUserCommunityRecords(string oldKey, string newKey, string newName) boolean (
+ array spaces = spaceDocs.all()
+ for i spaces.len (
+ object space = spaces[i].assert(object)
+ boolean changed = spaceRelatedUserKeys(space).contains(oldKey)
+ array likes = spaceLikes(space)
+ array brokenHearts = spaceBrokenHearts(space)
+ if stringArrayContainsUser(likes, oldKey) or stringArrayContainsUser(brokenHearts, oldKey) (
+ changed = true
+ )
+ if normalizeUsername(space.owner.toStr()) == oldKey (
+ space.owner = newName
+ )
+ space.managers = replaceUserInArray(spaceManagers(space), oldKey, newName)
+ space.followers = replaceUserInArray(space.followers.assertElse(array, []), oldKey, newKey)
+ space.likes = replaceUserInArray(likes, oldKey, newKey)
+ space.brokenHearts = replaceUserInArray(brokenHearts, oldKey, newKey)
+ array curatorInvites = spaceCuratorInvites(space)
+ for j curatorInvites.len (
+ object invite = curatorInvites[j].assert(object)
+ if normalizeUsername(invite.username.toStr()) == oldKey (
+ invite.username = newName
+ changed = true
+ )
+ if normalizeUsername(invite.invitedBy.toStr()) == oldKey (
+ invite.invitedBy = newName
+ changed = true
+ )
+ curatorInvites[j] = invite
+ )
+ space.curatorInvites = curatorInvites
+ if space.kind.toStr() == "challenge" (
+ space.participants = replaceUserInArray(challengeArray(space, "participants"), oldKey, newName)
+ space.judges = replaceUserInArray(challengeArray(space, "judges"), oldKey, newName)
+ array judgeInvites = challengeArray(space, "judgeInvites")
+ for j judgeInvites.len (
+ object invite = judgeInvites[j].assert(object)
+ if normalizeUsername(invite.username.toStr()) == oldKey (
+ invite.username = newName
+ changed = true
+ )
+ if normalizeUsername(invite.invitedBy.toStr()) == oldKey (
+ invite.invitedBy = newName
+ changed = true
+ )
+ judgeInvites[j] = invite
+ )
+ space.judgeInvites = judgeInvites
+ array scores = challengeArray(space, "scores")
+ for j scores.len (
+ object score = scores[j].assert(object)
+ if normalizeUsername(score.judge.toStr()) == oldKey (
+ score.judge = newName
+ scores[j] = score
+ changed = true
+ )
+ )
+ space.scores = scores
+ array votes = challengeArray(space, "audienceVotes")
+ for j votes.len (
+ object vote = votes[j].assert(object)
+ if normalizeUsername(vote.username.toStr()) == oldKey (
+ vote.username = newName
+ votes[j] = vote
+ changed = true
+ )
+ )
+ space.audienceVotes = votes
+ array submissions = challengeArray(space, "submissions")
+ for j submissions.len (
+ object submission = submissions[j].assert(object)
+ if normalizeUsername(submission.owner.toStr()) == oldKey (
+ submission.owner = newName
+ submissions[j] = submission
+ changed = true
+ )
+ )
+ space.submissions = submissions
+ )
+ if changed (
+ putSpaceDocument(space)
+ )
+ )
+
+ array reviews = reviewDocs.where("authorKey", "==", oldKey).all()
+ for i reviews.len (
+ object review = reviews[i].assert(object)
+ string oldId = review["_id"].toStr()
+ string newId = review.projectId.toStr() ++ ":" ++ newKey
+ review.author = newName
+ review.authorKey = newKey
+ void reviewDocs.deleteOne({_id: oldId})
+ putDocument(reviewDocs, newId, review)
+ )
+
+ array playtimeRecords = playtimeDocs.all()
+ for i playtimeRecords.len (
+ object playtime = playtimeRecords[i].assert(object)
+ if normalizeUsername(playtime.username.toStr()) == oldKey (
+ string oldId = playtime["_id"].toStr()
+ string newId = playtime.projectId.toStr() ++ ":" ++ newKey
+ object existing = (playtimeDocs.findById(newId) ?? {})
+ if existing.len > 0 and newId != oldId (
+ playtime.duration = playtime.duration.toNum() + existing.duration.toNum()
+ )
+ playtime.username = newName
+ void playtimeDocs.deleteOne({_id: oldId})
+ putDocument(playtimeDocs, newId, playtime)
+ )
+ )
+ object session = (playtimeSessionDocs.findById(oldKey) ?? {})
+ if session.len > 0 (
+ session.username = newName
+ void playtimeSessionDocs.deleteOne({_id: oldKey})
+ putDocument(playtimeSessionDocs, newKey, session)
+ )
+
+ array feedback = feedbackDocs.all()
+ for i feedback.len (
+ object item = feedback[i].assert(object)
+ if normalizeUsername(item.author.toStr()) == oldKey (
+ item.author = newName
+ putDocument(feedbackDocs, item["_id"].toStr(), item)
+ )
+ )
+ array releases = releaseDocs.all()
+ for i releases.len (
+ object release = releases[i].assert(object)
+ if normalizeUsername(release.creator.toStr()) == oldKey (
+ release.creator = newName
+ putDocument(releaseDocs, release["_id"].toStr(), release)
+ )
+ )
+ array pulls = pullDocs.all()
+ for i pulls.len (
+ object pull = pulls[i].assert(object)
+ boolean changed = false
+ if normalizeUsername(pull.author.toStr()) == oldKey (
+ pull.author = newName
+ changed = true
+ )
+ if normalizeUsername(pull.sourceOwner.toStr()) == oldKey (
+ pull.sourceOwner = newName
+ changed = true
+ )
+ if changed (
+ putDocument(pullDocs, pull["_id"].toStr(), pull)
+ )
+ )
+
+ array ideas = ideaDocs.all()
+ for i ideas.len (
+ object idea = ideas[i].assert(object)
+ boolean changed = false
+ if normalizeUsername(idea.author.toStr()) == oldKey (
+ idea.author = newName
+ changed = true
+ )
+ array likes = []
+ if idea.contains("likes") (
+ likes = idea["likes"].assertElse(array, [])
+ ) else if idea.contains("voters") (
+ likes = idea["voters"].assertElse(array, [])
+ )
+ array dislikes = []
+ if idea.contains("dislikes") (
+ dislikes = idea["dislikes"].assertElse(array, [])
+ )
+ if stringArrayContainsUser(likes, oldKey) or stringArrayContainsUser(dislikes, oldKey) (
+ idea.likes = replaceUserInArray(likes, oldKey, newName)
+ idea.dislikes = replaceUserInArray(dislikes, oldKey, newName)
+ idea.voters = []
+ changed = true
+ )
+ if changed (
+ putDocument(ideaDocs, idea["_id"].toStr(), idea)
+ )
+ )
+ return true
+)
diff --git a/src/community/playtime-diagnostics.osl b/src/community/playtime-diagnostics.osl
new file mode 100644
index 0000000..dabee84
--- /dev/null
+++ b/src/community/playtime-diagnostics.osl
@@ -0,0 +1,187 @@
+export {
+ creditPlaytimeSession,
+ recordProjectDiagnostic
+}
+
+def playtimeIdentity(string username) string (
+ string userId = userIdForUsername(username)
+ if userId != "" (
+ return userId
+ )
+ return normalizeUsername(username)
+)
+
+def beginPlaytimeSession(string projectId, string username, string sessionId) boolean (
+ string userId = userIdForUsername(username)
+ string identityKey = playtimeIdentity(username)
+ object session = (playtimeSessionDocs.findById(sessionId) ?? {})
+ if session.len == 0 or session.projectId.toStr() != projectId or timestamp - session.lastSeen.toNum() > 120000 (
+ putDocument(playtimeSessionDocs, sessionId, {
+ _id: sessionId,
+ projectId: projectId,
+ username: username,
+ userId: userId,
+ identityKey: identityKey,
+ lastCredit: timestamp,
+ lastSeen: timestamp
+ })
+ )
+ return true
+)
+
+def touchRecentProjectActivity(string projectId, string username) boolean (
+ string userId = userIdForUsername(username)
+ string identityKey = userId
+ if identityKey == "" (
+ identityKey = normalizeUsername(username)
+ )
+ string playtimeId = projectId ++ ":" ++ identityKey
+ string playtimeLock = "playtime:" ++ playtimeId
+ sync.lock(playtimeLock)
+ object playtime = (playtimeDocs.findById(playtimeId) ?? {})
+ if playtime.len == 0 (
+ playtime = {_id: playtimeId, projectId: projectId, username: username, userId: userId, duration: 0, updated: timestamp}
+ ) else (
+ playtime.username = username
+ playtime.userId = userId
+ playtime.updated = timestamp
+ )
+ playtime.projectId = projectId
+ boolean saved = putDocument(playtimeDocs, playtimeId, playtime)
+ sync.unlock(playtimeLock)
+ return saved
+)
+
+def creditPlaytimeSession(string projectId, string username, string sessionId, boolean finished) number (
+ string userId = userIdForUsername(username)
+ object session = (playtimeSessionDocs.findById(sessionId) ?? {})
+ if session.len == 0 or session.projectId.toStr() != projectId (
+ return 0
+ )
+ number credited = clampNumber(timestamp - session.lastCredit.toNum(), 0, 60000)
+ if credited > 0 (
+ string identityKey = session.identityKey.assertElse(string, playtimeIdentity(username))
+ string playtimeId = projectId ++ ":" ++ identityKey
+ string playtimeLock = "playtime:" ++ playtimeId
+ sync.lock(playtimeLock)
+ object playtime = (playtimeDocs.findById(playtimeId) ?? {})
+ if playtime.len == 0 (
+ playtime = {_id: playtimeId, projectId: projectId, username: username, userId: userId, duration: 0, updated: timestamp}
+ )
+ playtime.duration = playtime.duration.toNum() + credited
+ playtime.updated = timestamp
+ playtime.projectId = projectId
+ putDocument(playtimeDocs, playtimeId, playtime)
+ sync.unlock(playtimeLock)
+ )
+ if finished (
+ void playtimeSessionDocs.deleteOne({_id: sessionId})
+ ) else (
+ session.lastCredit = timestamp
+ session.lastSeen = timestamp
+ putDocument(playtimeSessionDocs, sessionId, session)
+ )
+ return credited
+)
+
+def recordProjectDiagnostic(string projectId, boolean authenticated, string username, string sessionId, object body) object (
+ string eventType = body.type.toStr().toLower().strip()
+ if eventType != "load" and eventType != "start" and eventType != "crash" and eventType != "exit" and eventType != "playtime" and eventType != "playtime_start" and eventType != "playtime_ping" and eventType != "playtime_finish" (
+ return {ok: false, error: "invalid diagnostic type"}
+ )
+ string diagnosticId = projectId ++ ":" ++ randomString(16)
+ number duration = 0
+ number loadMs = 0
+ string errorText = ""
+ string device = ""
+ if body.contains("duration") (
+ duration = clampNumber(body.duration.toNum(), 0, 86400000)
+ )
+ if body.contains("loadMs") (
+ loadMs = clampNumber(body.loadMs.toNum(), 0, 600000)
+ )
+ if body.contains("error") (
+ errorText = body.error.toStr().trim(1, 500)
+ )
+ if body.contains("device") (
+ device = body.device.toStr().trim(1, 40)
+ )
+ if eventType == "playtime_start" or eventType == "playtime_ping" or eventType == "playtime_finish" or eventType == "playtime" (
+ if !authenticated (
+ return {ok: true}
+ )
+ if eventType == "playtime_start" (
+ beginPlaytimeSession(projectId, username, sessionId)
+ ) else if eventType == "playtime_finish" (
+ creditPlaytimeSession(projectId, username, sessionId, true)
+ ) else (
+ creditPlaytimeSession(projectId, username, sessionId, false)
+ )
+ return {ok: true}
+ )
+ if eventType == "start" and authenticated (
+ beginPlaytimeSession(projectId, username, sessionId)
+ )
+ if eventType == "load" and authenticated (
+ touchRecentProjectActivity(projectId, username)
+ )
+ object item = {_id: diagnosticId, projectId: projectId, type: eventType, duration: duration, loadMs: loadMs, error: errorText, device: device, created: timestamp}
+ string diagnosticLock = "project-diagnostics-" ++ projectId
+ sync.lock(diagnosticLock)
+ void diagnosticDocs.insertOne(item)
+ array storedDiagnostics = diagnosticDocs.where("projectId", "==", projectId).sort("created", "desc").limit(550).all()
+ for i storedDiagnostics.len (
+ if i > 500 (
+ void diagnosticDocs.deleteOne({_id: storedDiagnostics[i]["_id"]})
+ )
+ )
+ sync.unlock(diagnosticLock)
+ return {ok: true}
+)
+
+def handleRecordDiagnostic(*serve.Context c) (
+ auto projectResult = loadProject(c.param("id"))
+ if !projectResult.ok or !projectIsViewable(projectResult.project.assert(object)) (
+ c.notFound("project not found")
+ return
+ )
+ string sessionId = playtimeIdentity(contextString(c, "username"))
+ object res = recordProjectDiagnostic(
+ c.param("id"),
+ contextBool(c, "authenticated"),
+ contextString(c, "username"),
+ sessionId,
+ requestJSON(c)
+ )
+ if !res.ok (
+ c.badRequest(res.error.toStr())
+ return
+ )
+ c.json(201, {ok: true})
+)
+
+def handleGetDiagnostics(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !canEditProject(res.project.assert(object), c) (
+ c.forbidden("not your project")
+ return
+ )
+ array items = diagnosticDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(500).all()
+ object counts = {load: 0, start: 0, crash: 0, exit: 0}
+ number totalLoadMs = 0
+ number loadSamples = 0
+ for i items.len (
+ object item = items[i].assert(object)
+ string kind = item.type.toStr()
+ counts[kind] = counts[kind].toNum() + 1
+ if item.loadMs.toNum() > 0 (
+ totalLoadMs += item.loadMs.toNum()
+ loadSamples += 1
+ )
+ )
+ number averageLoadMs = 0
+ if loadSamples > 0 (
+ averageLoadMs = totalLoadMs / loadSamples
+ )
+ c.json(200, {ok: true, counts: counts, averageLoadMs: averageLoadMs, recent: paginate(items, 0, 50)})
+)
diff --git a/src/community/project-feedback.osl b/src/community/project-feedback.osl
new file mode 100644
index 0000000..f3967ae
--- /dev/null
+++ b/src/community/project-feedback.osl
@@ -0,0 +1,174 @@
+def handleCreateProjectFeedback(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !canViewProject(res.project.assert(object), c) (
+ c.notFound("project not found")
+ return
+ )
+ object project = res.project.assert(object)
+ if feedbackDocs.where("projectId", "==", project.id.toStr()).limit(200).all().len >= 200 (
+ c.badRequest("this project has reached its feedback limit")
+ return
+ )
+ object body = requestJSON(c)
+ string feedbackType = body.type.toStr().toLower().strip()
+ if feedbackType != "bug" and feedbackType != "idea" and feedbackType != "confusing" and feedbackType != "other" (
+ c.badRequest("invalid feedback type")
+ return
+ )
+ string message = body.message.toStr().strip()
+ if message == "" or message.len > 2000 (
+ c.badRequest("feedback must be between 1 and 2000 characters")
+ return
+ )
+ string feedbackId = project.id.toStr() ++ ":" ++ randomString(16)
+ object feedback = {_id: feedbackId, projectId: project.id, type: feedbackType, message: message, status: "open", author: contextString(c, "username"), authorId: contextString(c, "userId"), created: timestamp, edited: timestamp}
+ void feedbackDocs.insertOne(feedback)
+ addNotification(project.owner.toStr(), contextString(c, "username"), "project_feedback", {projectId: project.id, projectTitle: project.title, feedbackType: feedbackType})
+ c.json(201, {ok: true})
+)
+
+def handleGetProjectFeedback(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !canEditProject(res.project.assert(object), c) (
+ c.forbidden("not your project")
+ return
+ )
+ array feedback = feedbackDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(200).all()
+ c.json(200, {ok: true, feedback: feedback})
+)
+
+def handleUpdateProjectFeedback(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !canEditProject(res.project.assert(object), c) (
+ c.forbidden("not your project")
+ return
+ )
+ object feedback = (feedbackDocs.findById(c.param("feedback")) ?? {})
+ if feedback.len == 0 or feedback.projectId.toStr() != c.param("id") (
+ c.notFound("feedback not found")
+ return
+ )
+ string status = requestJSON(c).status.toStr().toLower()
+ if status != "open" and status != "working" and status != "done" and status != "dismissed" (
+ c.badRequest("invalid feedback status")
+ return
+ )
+ feedback.status = status
+ feedback.edited = timestamp
+ putDocument(feedbackDocs, feedback["_id"].toStr(), feedback)
+ c.json(200, {ok: true, feedback: feedback})
+)
+
+def handleListProjectReviews(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !canViewProject(res.project.assert(object), c) (
+ c.notFound("project not found")
+ return
+ )
+ array reviews = reviewsVisibleTo(reviewDocs.where("projectId", "==", c.param("id")).sort("edited", "desc").limit(200).all(), c)
+ number ratingTotal = 0
+ object myReview = {}
+ for i reviews.len (
+ object review = reviews[i].assert(object)
+ ratingTotal += review.rating.toNum()
+ review.playtimeMs = authorPublicPlaytime(c.param("id"), review.author.toStr())
+ string authorId = ""
+ if review.contains("authorId") and review.authorId != null (
+ authorId = review.authorId.toStr()
+ )
+ if identityMatches(authorId, review.author.toStr(), contextString(c, "userId"), contextString(c, "username")) (
+ myReview = review
+ )
+ reviews[i] = review
+ )
+ number average = 0
+ if reviews.len > 0 (
+ average = ratingTotal / reviews.len
+ )
+ c.json(200, {ok: true, reviews: reviews, count: reviews.len, average: average, myReview: myReview})
+)
+
+def handleListUserReviews(*serve.Context c) (
+ string name = normalizeUsername(c.param("name"))
+ string requestedId = userIdForUsername(name)
+ number limit = clampNumber(c.queryInt("limit", 20), 1, 50)
+ array allReviews = reviewDocs.sort("edited", "desc").limit(1000).all()
+ array reviews = []
+ for i allReviews.len (
+ object review = allReviews[i].assert(object)
+ string authorId = ""
+ if review.contains("authorId") and review.authorId != null (
+ authorId = review.authorId.toStr()
+ )
+ if identityMatches(authorId, review.author.toStr(), requestedId, name) and reviews.len < limit and viewerSeesShadowBanned(review.author.toStr(), authorId, c) (
+ auto res = loadProject(review.projectId.toStr())
+ if res.ok and canViewProject(res.project.assert(object), c) (
+ object project = res.project.assert(object)
+ review.projectTitle = project.title
+ review.projectOwner = project.owner
+ review.playtimeMs = authorPublicPlaytime(review.projectId.toStr(), review.author.toStr())
+ reviews.append(review)
+ )
+ )
+ )
+ c.json(200, {ok: true, reviews: reviews})
+)
+
+def handleSaveProjectReview(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !canViewProject(res.project.assert(object), c) (
+ c.notFound("project not found")
+ return
+ )
+ object project = res.project.assert(object)
+ if projectRole(project, contextString(c, "username")) == "owner" (
+ c.badRequest("you cannot review your own project")
+ return
+ )
+ object body = requestJSON(c)
+ number rating = body.rating.toInt()
+ if rating < 1 or rating > 5 (
+ c.badRequest("rating must be between 1 and 5")
+ return
+ )
+ string message = ""
+ if body.contains("message") (
+ message = body.message.toStr().strip()
+ )
+ if message.len > 2000 (
+ c.badRequest("review is too long")
+ return
+ )
+ string reviewerId = contextString(c, "userId")
+ if reviewerId == "" (
+ reviewerId = normalizeUsername(contextString(c, "username"))
+ )
+ string reviewId = project.id.toStr() ++ ":" ++ reviewerId
+ object existing = (reviewDocs.findById(reviewId) ?? {})
+ number created = timestamp
+ if existing.len > 0 (
+ created = existing.created.toNum()
+ )
+ object review = {_id: reviewId, projectId: project.id, author: contextString(c, "username"), authorId: contextString(c, "userId"), authorKey: normalizeUsername(contextString(c, "username")), rating: rating, message: message, created: created, edited: timestamp}
+ putDocument(reviewDocs, reviewId, review)
+ if existing.len == 0 (
+ addNotification(project.owner.toStr(), contextString(c, "username"), "project_review", {projectId: project.id, projectTitle: project.title, rating: rating})
+ )
+ review.playtimeMs = authorPublicPlaytime(project.id.toStr(), review.author.toStr())
+ c.json(200, {ok: true, review: review})
+)
+
+def handleDeleteProjectReview(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !canViewProject(res.project.assert(object), c) (
+ c.notFound("project not found")
+ return
+ )
+ string reviewerId = contextString(c, "userId")
+ if reviewerId == "" (
+ reviewerId = normalizeUsername(contextString(c, "username"))
+ )
+ string reviewId = c.param("id") ++ ":" ++ reviewerId
+ void reviewDocs.deleteOne({_id: reviewId})
+ c.json(200, {ok: true})
+)
diff --git a/src/community/releases.osl b/src/community/releases.osl
new file mode 100644
index 0000000..e22f161
--- /dev/null
+++ b/src/community/releases.osl
@@ -0,0 +1,217 @@
+def handleCreatePreview(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok (
+ c.notFound("project not found")
+ return
+ )
+ object project = res.project.assert(object)
+ if !canEditProject(project, c) (
+ c.forbidden("not your project")
+ return
+ )
+ if projectMustStayPrivate(project) (
+ c.forbidden("remixes of paid projects cannot create shareable preview links")
+ return
+ )
+ array previews = previewDocs.where("projectId", "==", project.id.toStr()).all()
+ number activePreviews = 0
+ for i previews.len (
+ object existing = previews[i].assert(object)
+ if existing.expiresAt.toNum() <= timestamp (
+ void previewDocs.deleteOne({_id: existing["_id"]})
+ ) else (
+ activePreviews += 1
+ )
+ )
+ if activePreviews >= 20 (
+ c.badRequest("a project can have at most 20 active previews")
+ return
+ )
+ object body = requestJSON(c)
+ number hours = 24
+ if body.contains("hours") (
+ hours = clampNumber(body.hours.toNum(), 1, 168)
+ )
+ string key = randomString(24)
+ string previewId = project.id.toStr() ++ ":" ++ key
+ object preview = {_id: previewId, projectId: project.id, key: key, owner: contextString(c, "username"), ownerId: contextString(c, "userId"), created: timestamp, expiresAt: timestamp + (hours * 3600000)}
+ void previewDocs.insertOne(preview)
+ c.json(201, {ok: true, key: key, expiresAt: preview.expiresAt})
+)
+
+def handleListReleases(*serve.Context c) (
+ c.setHeader("Cache-Control", "private, no-store")
+ c.setHeader("CDN-Cache-Control", "private, no-store")
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !canViewProject(res.project.assert(object), c) (
+ c.notFound("project not found")
+ return
+ )
+ object project = res.project.assert(object)
+ boolean canEdit = canEditProject(project, c)
+ ensureViewKey(project)
+ object jsonAccess = projectJsonAccess(project, c)
+ array stored = releaseDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(100).all()
+ array releases = []
+ for i stored.len (
+ object release = stored[i].assert(object)
+ if release.channel.toStr() != "checkpoint" or canEdit (
+ releases.append(publicReleaseRecord(release, jsonAccess))
+ )
+ )
+ c.json(200, {ok: true, releases: releases})
+)
+
+def handleCreateRelease(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok (
+ c.notFound("project not found")
+ return
+ )
+ object project = res.project.assert(object)
+ if !canEditProject(project, c) (
+ c.forbidden("not your project")
+ return
+ )
+ object body = requestJSON(c)
+ string version = body.version.toStr().strip()
+ string releaseChannel = body.channel.toStr().toLower().strip()
+ if version == "" or version.len > 50 (
+ c.badRequest("invalid version")
+ return
+ )
+ if releaseChannel != "stable" and releaseChannel != "beta" and releaseChannel != "development" and releaseChannel != "checkpoint" (
+ c.badRequest("invalid release channel")
+ return
+ )
+ if releaseChannel == "checkpoint" and mistwarpBenefitsForProject(project).historyCheckpoints != true (
+ c.forbidden("named history checkpoints require Rotur Lite, Plus, or Pro")
+ return
+ )
+ number releaseLimit = mistwarpBenefitsForProject(project).maxProjectReleases.toNum()
+ if releaseDocs.where("projectId", "==", project.id.toStr()).limit(releaseLimit.toInt()).all().len >= releaseLimit (
+ c.badRequest("this project has reached its release limit")
+ return
+ )
+ string notes = ""
+ if body.contains("notes") (
+ notes = body.notes.toStr().strip()
+ )
+ if notes.len > 10000 (
+ c.badRequest("release notes are too long")
+ return
+ )
+ string releaseId = project.id.toStr() ++ ":" ++ randomString(12)
+ string commit = ""
+ if body.contains("commit") (
+ commit = body.commit.toStr()
+ )
+ object snapshot = ensureProjectJsonSnapshot(project)
+ if !snapshot.ok (
+ c.internalError("could not snapshot the current project")
+ return
+ )
+ project = snapshot.project.assert(object)
+ string jsonUrl = appURL ++ "/v1/projects/" ++ project.id.toStr() ++ "/releases/" ++ url.escape(releaseId) ++ "/project.json"
+ // jsonBytes is the snapshot's size, which counts against the owner's
+ // storage for as long as the release keeps it.
+ object release = {_id: releaseId, projectId: project.id, version: version, channel: releaseChannel, notes: notes, commit: commit, jsonKey: project.jsonKey, jsonBytes: (project.storedJsonBytes ?? 0).toNum(), jsonGzip: true, jsonUrl: jsonUrl, assetsBase: assetsBaseUrl(), created: timestamp, creator: contextString(c, "username"), creatorId: contextString(c, "userId")}
+ void releaseDocs.insertOne(release)
+ storageSettleProject(project)
+ if releaseChannel == "stable" (
+ project.publicRelease = releaseId
+ saveProject(project)
+ )
+ if releaseChannel != "checkpoint" (
+ addActivity(contextString(c, "username"), "release", {projectId: project.id, projectTitle: project.title, version: version})
+ )
+ c.json(201, {ok: true, release: publicReleaseRecord(release, projectJsonAccess(project, c))})
+)
+
+def handleGetReleaseProjectJson(*serve.Context c) (
+ object release = (releaseDocs.findById(c.param("release")) ?? {})
+ if release.len == 0 or release.projectId.toStr() != c.param("id") (
+ c.notFound("release not found")
+ return
+ )
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !canViewProjectJson(res.project.assert(object), c) (
+ c.notFound("release not found")
+ return
+ )
+ string releaseEtag = "\"" ++ release["_id"].toStr() ++ "\""
+ c.setHeader("ETag", releaseEtag)
+ c.setHeader("Cache-Control", "no-cache")
+ if (c.header("If-None-Match") ?? "").contains(releaseEtag) (
+ c.status(304)
+ return
+ )
+ if release.contains("jsonKey") and release.jsonKey.toStr() != "" (
+ serveProjectJsonStorageKey(c, release.jsonKey.toStr(), release.contains("jsonGzip") and release.jsonGzip == true)
+ return
+ )
+ string releasePath = releasesDir ++ release["_id"].toStr().replace(":", "-") ++ ".json"
+ if !fs.exists(releasePath) (
+ c.notFound("release snapshot not found")
+ return
+ )
+ c.setHeader("Content-Type", "application/json")
+ c.text(200, fs.readFile(releasePath).unwrapOr(""))
+)
+
+def handleDeleteRelease(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok (
+ c.notFound("project not found")
+ return
+ )
+ object project = res.project.assert(object)
+ if !canEditProject(project, c) (
+ c.forbidden("not your project")
+ return
+ )
+ object release = (releaseDocs.findById(c.param("release")) ?? {})
+ if release.len == 0 or release.projectId.toStr() != project.id.toStr() (
+ c.notFound("release not found")
+ return
+ )
+ string jsonKey = (release.jsonKey ?? "").toStr()
+ void releaseDocs.deleteOne({_id: release["_id"]})
+ void fs.remove(releasesDir ++ release["_id"].toStr().replace(":", "-") ++ ".json")
+ if jsonKey != "" (
+ removeProjectStorageKeyIfOrphaned(jsonKey, "", false)
+ )
+ storageSettleProject(project)
+ if project.publicRelease.toStr() == release["_id"].toStr() (
+ project.publicRelease = ""
+ saveProject(project)
+ )
+ c.json(200, {ok: true})
+)
+
+def handleCreateContribution(*serve.Context c) (
+ auto parentResult = loadProject(c.param("id"))
+ object body = requestJSON(c)
+ auto remixResult = loadProject(body.remixProjectId.toStr())
+ if !parentResult.ok or !remixResult.ok (
+ c.notFound("project not found")
+ return
+ )
+ object parent = parentResult.project.assert(object)
+ object remix = remixResult.project.assert(object)
+ string title = body.title.toStr().strip()
+ string description = ""
+ if body.contains("body") (
+ description = body.body.toStr()
+ )
+ string bountyId = ""
+ if body.contains("bountyId") and body.bountyId != null (
+ bountyId = body.bountyId.toStr().strip()
+ )
+ object created = createNativePull(c, parent, remix, title, description, bountyId)
+ if !created.ok (
+ c.json(created.status.toInt(), {ok: false, error: created.error})
+ return
+ )
+ c.json(201, created)
+)
diff --git a/src/community/roadmap.osl b/src/community/roadmap.osl
new file mode 100644
index 0000000..0f13b0e
--- /dev/null
+++ b/src/community/roadmap.osl
@@ -0,0 +1,281 @@
+def handleListIdeas(*serve.Context c) (
+ array ideas = ideaDocs.sort("created", "desc").all()
+ string username = contextString(c, "username")
+ array developmentFeed = developmentPulls()
+ for i ideas.len (
+ object idea = ideas[i].assert(object)
+ boolean needsSave = false
+ if !idea.contains("source") (
+ idea.source = "community"
+ if normalizeUsername(idea.author.toStr()) == "mist" (
+ idea.source = "mistwarp"
+ )
+ needsSave = true
+ )
+ if !idea.contains("interested") (
+ idea.interested = idea.source.toStr() == "mistwarp"
+ needsSave = true
+ )
+ if !idea.contains("kind") (
+ idea.kind = "idea"
+ needsSave = true
+ )
+ if !idea.contains("commentCount") (
+ idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len
+ needsSave = true
+ )
+ if needsSave (
+ putDocument(ideaDocs, idea["_id"].toStr(), idea)
+ )
+ array likes = []
+ array dislikes = []
+ if idea.contains("likes") (
+ likes = idea["likes"].assertElse(array, [])
+ ) else if idea.contains("voters") (
+ likes = idea["voters"].assertElse(array, [])
+ )
+ if idea.contains("dislikes") (
+ dislikes = idea["dislikes"].assertElse(array, [])
+ )
+ idea.likesCount = likes.len
+ idea.dislikesCount = dislikes.len
+ idea.score = likes.len - dislikes.len
+ idea.myVote = ""
+ if stringArrayContainsUser(likes, username) (
+ idea.myVote = "like"
+ ) else if stringArrayContainsUser(dislikes, username) (
+ idea.myVote = "dislike"
+ )
+ idea.likes = []
+ idea.dislikes = []
+ idea.voters = []
+ idea.pullRequests = resolveIdeaPulls(idea, developmentFeed)
+ ideas[i] = idea
+ )
+ ideas.sortBy("score", "descending")
+ c.json(200, {ok: true, ideas: ideas})
+)
+
+def handleCreateIdea(*serve.Context c) (
+ object body = requestJSON(c)
+ string title = body.title.toStr().strip()
+ string description = body.description.toStr().strip()
+ if title == "" or title.len > 120 or description == "" or description.len > 3000 (
+ c.badRequest("add a title and a description")
+ return
+ )
+ string category = "Other"
+ if body.contains("category") (
+ category = body.category.toStr().trim(1, 40)
+ )
+ string kind = "idea"
+ if body.contains("kind") (
+ kind = body.kind.toStr().toLower()
+ )
+ if kind != "idea" and kind != "bug" (
+ c.badRequest("invalid roadmap entry type")
+ return
+ )
+ string ideaId = "i" ++ randomString(14)
+ string source = "community"
+ if contextBool(c, "isAdmin") (
+ source = "mistwarp"
+ )
+ object idea = {_id: ideaId, title: title, description: description, category: category, kind: kind, author: contextString(c, "username"), authorId: contextString(c, "userId"), source: source, interested: source == "mistwarp", status: "open", pulls: [], likes: [contextString(c, "username")], dislikes: [], votes: 1, commentCount: 0, created: timestamp, edited: timestamp}
+ void ideaDocs.insertOne(idea)
+ idea.myVote = "like"
+ idea.likesCount = 1
+ idea.dislikesCount = 0
+ idea.score = 1
+ idea.commentCount = 0
+ idea.likes = []
+ idea.dislikes = []
+ idea.pullRequests = []
+ c.json(201, {ok: true, idea: idea})
+)
+
+def handleVoteIdea(*serve.Context c) (
+ string lockName = "idea-reactions:" ++ c.param("id")
+ sync.lock(lockName)
+ handleVoteIdeaLocked(c)
+ sync.unlock(lockName)
+)
+
+def handleVoteIdeaLocked(*serve.Context c) (
+ object idea = (ideaDocs.findById(c.param("id")) ?? {})
+ if idea.len == 0 (
+ c.notFound("idea not found")
+ return
+ )
+ string username = contextString(c, "username")
+ string vote = requestJSON(c).vote.toStr().toLower()
+ if vote != "like" and vote != "dislike" (
+ c.badRequest("vote must be like or dislike")
+ return
+ )
+ array likes = []
+ array dislikes = []
+ if idea.contains("likes") (
+ likes = idea["likes"].assertElse(array, [])
+ ) else if idea.contains("voters") (
+ likes = idea["voters"].assertElse(array, [])
+ )
+ if idea.contains("dislikes") (
+ dislikes = idea["dislikes"].assertElse(array, [])
+ )
+ number previousLikes = likes.len
+ boolean hadVote = false
+ if vote == "like" (
+ hadVote = stringArrayContainsUser(likes, username)
+ ) else (
+ hadVote = stringArrayContainsUser(dislikes, username)
+ )
+ likes = removeUser(likes, username)
+ dislikes = removeUser(dislikes, username)
+ if !hadVote and vote == "like" (
+ likes.append(username)
+ ) else if !hadVote and vote == "dislike" (
+ dislikes.append(username)
+ )
+ idea.likes = likes
+ idea.dislikes = dislikes
+ idea.votes = likes.len - dislikes.len
+ idea.edited = timestamp
+ if !putDocument(ideaDocs, idea["_id"].toStr(), idea) (
+ c.internalError("could not save reaction")
+ return
+ )
+ notifyLikeMilestones(idea.author.toStr(), "roadmap post", idea["_id"].toStr(), previousLikes, likes.len, "/roadmap#idea-" ++ idea["_id"].toStr(), {})
+ string myVote = ""
+ if !hadVote (
+ myVote = vote
+ )
+ c.json(200, {ok: true, myVote: myVote, likesCount: likes.len, dislikesCount: dislikes.len, score: likes.len - dislikes.len})
+)
+
+def handleGetIdeaComments(*serve.Context c) (
+ object idea = (ideaDocs.findById(c.param("id")) ?? {})
+ if idea.len == 0 (
+ c.notFound("idea not found")
+ return
+ )
+ object page = commentPage(loadComments("roadmap-" ++ idea["_id"].toStr()), c)
+ page.ok = true
+ c.json(200, page)
+)
+
+def handleCreateIdeaComment(*serve.Context c) (
+ object idea = (ideaDocs.findById(c.param("id")) ?? {})
+ if idea.len == 0 (
+ c.notFound("idea not found")
+ return
+ )
+ object body = requestJSON(c)
+ if !body.contains("content") or body.content == null (
+ c.badRequest("comment content is required")
+ return
+ )
+ string parentId = ""
+ if body.contains("parent") and body.parent != null (
+ parentId = body.parent.toStr()
+ )
+ object created = createComment("roadmap-" ++ idea["_id"].toStr(), contextString(c, "username"), contextString(c, "userId"), body.content.toStr(), parentId, "comment", 0, "")
+ if !created.ok (
+ c.badRequest(created.error.toStr())
+ return
+ )
+ if idea.contains("commentCount") (
+ idea.commentCount = idea.commentCount.toNum() + 1
+ ) else (
+ idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len
+ )
+ putDocument(ideaDocs, idea["_id"].toStr(), idea)
+ string commenter = contextString(c, "username")
+ string parentAuthor = created.parentAuthor.toStr()
+ string preview = created.comment.content.toStr()
+ if parentAuthor != "" and normalizeUsername(parentAuthor) != normalizeUsername(commenter) (
+ addNotification(parentAuthor, commenter, "roadmap_comment", {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview})
+ )
+ if normalizeUsername(idea.author.toStr()) != normalizeUsername(parentAuthor) (
+ addNotification(idea.author.toStr(), commenter, "roadmap_comment", {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview})
+ )
+ notifyMentions(created.comment.content.toStr(), commenter, [idea.author.toStr(), parentAuthor], {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview})
+ c.json(201, created)
+)
+
+def handleDeleteIdeaComment(*serve.Context c) (
+ object idea = (ideaDocs.findById(c.param("id")) ?? {})
+ if idea.len == 0 (
+ c.notFound("idea not found")
+ return
+ )
+ object deleted = deleteComment("roadmap-" ++ idea["_id"].toStr(), c.param("cid"), contextString(c, "username"), idea.author.toStr(), contextBool(c, "isAdmin"))
+ if !deleted.ok (
+ c.json(deleted.status.toInt(), {ok: false, error: deleted.error})
+ return
+ )
+ if idea.contains("commentCount") (
+ idea.commentCount = clampNumber(
+ idea.commentCount.toNum() - deleted.removed.toNum(),
+ 0,
+ idea.commentCount.toNum()
+ )
+ ) else (
+ idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len
+ )
+ putDocument(ideaDocs, idea["_id"].toStr(), idea)
+ c.json(200, {ok: true})
+)
+
+def handleEditIdeaComment(*serve.Context c) (
+ object idea = (ideaDocs.findById(c.param("id")) ?? {})
+ if idea.len == 0 (
+ c.notFound("idea not found")
+ return
+ )
+ object body = requestJSON(c)
+ if !body.contains("content") or body.content == null (
+ c.badRequest("comment content is required")
+ return
+ )
+ object edited = editComment("roadmap-" ++ idea["_id"].toStr(), c.param("cid"), contextString(c, "username"), body.content.toStr())
+ if !edited.ok (
+ c.json(edited.status.toInt(), {ok: false, error: edited.error})
+ return
+ )
+ c.json(200, edited)
+)
+
+def handleReactIdeaComment(*serve.Context c) (
+ object idea = (ideaDocs.findById(c.param("id")) ?? {})
+ if idea.len == 0 (
+ c.notFound("idea not found")
+ return
+ )
+ reactToComment(c, "roadmap-" ++ idea["_id"].toStr(), "/roadmap?idea=" ++ idea["_id"].toStr())
+)
+
+def handleUpdateIdea(*serve.Context c) (
+ object idea = (ideaDocs.findById(c.param("id")) ?? {})
+ if idea.len == 0 (
+ c.notFound("idea not found")
+ return
+ )
+ object body = requestJSON(c)
+ if body.contains("status") (
+ string status = body.status.toStr().toLower()
+ if status == "open" or status == "planned" or status == "building" or status == "shipped" or status == "declined" (
+ idea.status = status
+ )
+ )
+ if body.contains("interested") (
+ idea.interested = body.interested == true
+ )
+ if body.contains("pulls") and typeof(body.pulls) == "array" (
+ idea.pulls = normalizePullLinks(body.pulls.assert(array))
+ )
+ idea.edited = timestamp
+ putDocument(ideaDocs, idea["_id"].toStr(), idea)
+ idea.pullRequests = resolveIdeaPulls(idea, developmentPulls())
+ c.json(200, {ok: true, idea: idea})
+)
diff --git a/src/community/share-cards.osl b/src/community/share-cards.osl
new file mode 100644
index 0000000..70ef52d
--- /dev/null
+++ b/src/community/share-cards.osl
@@ -0,0 +1,74 @@
+export {
+ warmShareCard,
+ xmlEscape
+}
+
+def xmlEscape(string value) string (
+ return value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
+)
+
+def handleProjectCard(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !projectIsViewable(res.project.assert(object)) (
+ c.notFound("project not found")
+ return
+ )
+ object project = res.project.assert(object)
+ string title = xmlEscape(project.title.toStr()).trim(1, 58)
+ string owner = xmlEscape(project.owner.toStr()).trim(1, 30)
+ string thumbnail = xmlEscape(thumbUrlFor(project))
+ number hearts = project.loves.assert(array).len
+ string svg = ""
+ c.setHeader("Cache-Control", "public, max-age=300")
+ c.send(200, "image/svg+xml; charset=utf-8", storagefs.Bytes(svg))
+)
+
+def shareCardPath(object project) string (
+ return cardsDir ++ project.id.toStr() ++ "-" ++ project.edited.toStr() ++ ".png"
+)
+
+def shareCardVisible(object project) boolean (
+ return project.shared == true and projectIsViewable(project) and !projectHiddenByModerator(project)
+)
+
+def ensureShareCard(object project) string (
+ string path = shareCardPath(project)
+ if fs.exists(path) (
+ return ""
+ )
+ return sharecard.Render(thumbnailFileFor(project), project.title.toStr(), project.owner.toStr(), path)
+)
+
+def warmShareCard(string id) (
+ auto res = loadProject(id)
+ if res.ok and shareCardVisible(res.project.assert(object)) (
+ void ensureShareCard(res.project.assert(object))
+ )
+)
+
+def handleProjectSharePng(*serve.Context c) (
+ auto res = loadProject(c.param("id"))
+ if !res.ok or !shareCardVisible(res.project.assert(object)) (
+ c.notFound("project not found")
+ return
+ )
+ object project = res.project.assert(object)
+ if ensureShareCard(project) != "" (
+ string cover = thumbUrlFor(project)
+ c.setHeader("Location", if cover == "" then editorOrigin ++ "/images/apple-touch-icon.png" else cover)
+ c.setHeader("Cache-Control", "public, max-age=300")
+ c.status(302)
+ return
+ )
+ boolean current = c.query("v") == project.edited.toStr()
+ string etag = "\"" ++ project.id.toStr() ++ "-" ++ project.edited.toStr() ++ "\""
+ c.setHeader("ETag", etag)
+ c.setHeader("Cache-Control", if current then "public, max-age=31536000, immutable" else "public, max-age=3600")
+ if (c.header("If-None-Match") ?? "") == etag (
+ c.status(304)
+ return
+ )
+ c.setHeader("Content-Type", "image/png")
+ c.setHeader("X-Content-Type-Options", "nosniff")
+ c.file(shareCardPath(project))
+)
diff --git a/src/community/space-comments.osl b/src/community/space-comments.osl
new file mode 100644
index 0000000..dbe4e53
--- /dev/null
+++ b/src/community/space-comments.osl
@@ -0,0 +1,126 @@
+def handleGetSpaceComments(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !canViewSpace(space, c) (
+ c.notFound("space not found")
+ return
+ )
+ object page = commentPage(loadComments(spaceCommentKey(space["_id"].toStr())), c)
+ page.ok = true
+ c.json(200, page)
+)
+
+def handleCreateSpaceComment(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !canViewSpace(space, c) (
+ c.notFound("space not found")
+ return
+ )
+ object body = requestJSON(c)
+ if !body.contains("content") or body.content == null (
+ c.badRequest("comment content is required")
+ return
+ )
+ string parentId = ""
+ if body.contains("parent") and body.parent != null (
+ parentId = body.parent.toStr()
+ )
+ object created = createComment(spaceCommentKey(space["_id"].toStr()), contextString(c, "username"), contextString(c, "userId"), body.content.toStr(), parentId, "comment", 0, "")
+ if !created.ok (
+ c.badRequest(created.error.toStr())
+ return
+ )
+ if space.contains("commentCount") (
+ space.commentCount = space.commentCount.toNum() + 1
+ ) else (
+ space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len
+ )
+ putSpaceDocument(space)
+ string commenter = contextString(c, "username")
+ string parentAuthor = created.parentAuthor.toStr()
+ string newCommentId = created.comment.id.toStr()
+ object notificationData = {spaceId: space["_id"], spaceTitle: space.title, commentId: newCommentId, preview: created.comment.content.toStr()}
+ if parentAuthor != "" and normalizeUsername(parentAuthor) != normalizeUsername(commenter) (
+ addNotification(parentAuthor, commenter, "reply", notificationData)
+ )
+ if normalizeUsername(space.owner.toStr()) != normalizeUsername(commenter) and normalizeUsername(space.owner.toStr()) != normalizeUsername(parentAuthor) (
+ addNotification(space.owner.toStr(), commenter, "space_comment", notificationData)
+ )
+ notifyMentions(created.comment.content.toStr(), commenter, [space.owner.toStr(), parentAuthor], notificationData)
+ c.json(201, created)
+)
+
+def handleDeleteSpaceComment(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ string moderator = space.owner.toStr()
+ if canManageSpace(space, c) (
+ moderator = contextString(c, "username")
+ )
+ object deleted = deleteComment(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), moderator, contextBool(c, "isAdmin"))
+ if !deleted.ok (
+ c.json(deleted.status.toInt(), {ok: false, error: deleted.error})
+ return
+ )
+ if space.contains("commentCount") (
+ space.commentCount = clampNumber(space.commentCount.toNum() - deleted.removed.toNum(), 0, space.commentCount.toNum())
+ ) else (
+ space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len
+ )
+ putSpaceDocument(space)
+ c.json(200, {ok: true})
+)
+
+def handleEditSpaceComment(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !canViewSpace(space, c) (
+ c.notFound("space not found")
+ return
+ )
+ object body = requestJSON(c)
+ if !body.contains("content") or body.content == null (
+ c.badRequest("comment content is required")
+ return
+ )
+ object edited = editComment(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), body.content.toStr())
+ if !edited.ok (
+ c.json(edited.status.toInt(), {ok: false, error: edited.error})
+ return
+ )
+ c.json(200, edited)
+)
+
+def handlePinSpaceComment(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !canViewSpace(space, c) (
+ c.notFound("space not found")
+ return
+ )
+ string moderator = space.owner.toStr()
+ if canManageSpace(space, c) (
+ moderator = contextString(c, "username")
+ )
+ object body = requestJSON(c)
+ boolean pinned = true
+ if body.contains("pinned") and body.pinned != null (
+ pinned = body.pinned == true
+ )
+ object updated = setCommentPin(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), moderator, contextBool(c, "isAdmin"), pinned)
+ if !updated.ok (
+ c.json(updated.status.toInt(), {ok: false, error: updated.error})
+ return
+ )
+ updated.comment = commentResponse(updated.comment.assert(object), contextString(c, "username"))
+ c.json(200, updated)
+)
+
+def handleReactSpaceComment(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !canViewSpace(space, c) (
+ c.notFound("space not found")
+ return
+ )
+ reactToComment(c, spaceCommentKey(space["_id"].toStr()), "/spaces/" ++ space["_id"].toStr())
+)
diff --git a/src/community/space-memberships.osl b/src/community/space-memberships.osl
new file mode 100644
index 0000000..7ea63a9
--- /dev/null
+++ b/src/community/space-memberships.osl
@@ -0,0 +1,157 @@
+export {
+ migrateSpaceMemberships,
+ removeProjectFromSpaces,
+ removeProjectsFromSpaces,
+ syncSpaceMemberships
+}
+
+def appendSpaceUserKey(array keys, string username) array (
+ string key = normalizeUsername(username)
+ if key != "" and !keys.contains(key) (
+ keys.append(key)
+ )
+ return keys
+)
+
+def spaceRelatedUserKeys(object space) array (
+ array keys = appendSpaceUserKey([], space.owner.toStr())
+ array managers = spaceManagers(space)
+ for i managers.len (
+ keys = appendSpaceUserKey(keys, managers[i].toStr())
+ )
+ array followers = space.followers.assertElse(array, [])
+ for i followers.len (
+ keys = appendSpaceUserKey(keys, followers[i].toStr())
+ )
+ array curatorInvites = spaceCuratorInvites(space)
+ for i curatorInvites.len (
+ object invite = curatorInvites[i].assert(object)
+ keys = appendSpaceUserKey(keys, invite.username.toStr())
+ )
+ if space.kind.toStr() == "challenge" (
+ array participants = challengeArray(space, "participants")
+ for i participants.len (
+ keys = appendSpaceUserKey(keys, participants[i].toStr())
+ )
+ array judges = challengeArray(space, "judges")
+ for i judges.len (
+ keys = appendSpaceUserKey(keys, judges[i].toStr())
+ )
+ array judgeInvites = challengeArray(space, "judgeInvites")
+ for i judgeInvites.len (
+ object invite = judgeInvites[i].assert(object)
+ keys = appendSpaceUserKey(keys, invite.username.toStr())
+ )
+ )
+ return keys
+)
+
+def syncSpaceMemberships(object space) boolean (
+ string spaceId = space["_id"].toStr()
+ array keys = spaceRelatedUserKeys(space)
+ string signature = json.stringify(keys)
+ string stateId = "space:" ++ spaceId
+ object state = (spaceMemberDocs.findById(stateId) ?? {})
+ if state.len > 0 and state.signature.toStr() == signature (
+ return true
+ )
+ array previous = spaceMemberDocs.where("spaceId", "==", spaceId).all()
+ for i previous.len (
+ object membership = previous[i].assert(object)
+ void spaceMemberDocs.deleteOne({_id: membership["_id"]})
+ )
+ for i keys.len (
+ string userKey = keys[i].toStr()
+ void spaceMemberDocs.insertOne({_id: "member:" ++ userKey ++ ":" ++ spaceId, kind: "member", userKey: userKey, spaceId: spaceId})
+ )
+ return putDocument(spaceMemberDocs, stateId, {kind: "state", signature: signature})
+)
+
+def putSpaceDocument(object space) boolean (
+ boolean saved = putDocument(spaceDocs, space["_id"].toStr(), space)
+ if saved (
+ syncSpaceMemberships(space)
+ )
+ return saved
+)
+
+def deleteSpaceMemberships(string spaceId) boolean (
+ array memberships = spaceMemberDocs.where("spaceId", "==", spaceId).all()
+ for i memberships.len (
+ object membership = memberships[i].assert(object)
+ void spaceMemberDocs.deleteOne({_id: membership["_id"]})
+ )
+ void spaceMemberDocs.deleteOne({_id: "space:" ++ spaceId})
+ return true
+)
+
+def migrateSpaceMemberships() boolean (
+ string migrationId = "migration:space-members-v1"
+ if (spaceMemberDocs.findById(migrationId) ?? {}).len > 0 (
+ return true
+ )
+ array previous = spaceMemberDocs.all()
+ for i previous.len (
+ object document = previous[i].assert(object)
+ void spaceMemberDocs.deleteOne({_id: document["_id"]})
+ )
+ array spaces = spaceDocs.all()
+ for i spaces.len (
+ syncSpaceMemberships(spaces[i].assert(object))
+ )
+ return putDocument(spaceMemberDocs, migrationId, {kind: "migration", completed: timestamp})
+)
+
+def removeProjectsFromSpaces(array removedProjectIds) boolean (
+ array spaces = spaceDocs.all()
+ for i spaces.len (
+ object space = spaces[i].assert(object)
+ array storedProjectIds = space.projects.assertElse(array, [])
+ array projectIds = []
+ boolean changed = false
+ for j storedProjectIds.len (
+ if removedProjectIds.contains(storedProjectIds[j].toStr()) (
+ changed = true
+ ) else (
+ projectIds.append(storedProjectIds[j])
+ )
+ )
+ if changed (
+ space.projects = projectIds
+ array submissions = []
+ array storedSubmissions = challengeArray(space, "submissions")
+ for j storedSubmissions.len (
+ object submission = storedSubmissions[j].assert(object)
+ if !removedProjectIds.contains(submission.projectId.toStr()) (
+ submissions.append(submission)
+ )
+ )
+ space.submissions = submissions
+ array scores = []
+ array storedScores = challengeArray(space, "scores")
+ for j storedScores.len (
+ object score = storedScores[j].assert(object)
+ if !removedProjectIds.contains(score.projectId.toStr()) (
+ scores.append(score)
+ )
+ )
+ space.scores = scores
+ array votes = []
+ array storedVotes = challengeArray(space, "audienceVotes")
+ for j storedVotes.len (
+ object vote = storedVotes[j].assert(object)
+ if !removedProjectIds.contains(vote.projectId.toStr()) (
+ votes.append(vote)
+ )
+ )
+ space.audienceVotes = votes
+ space.edited = timestamp
+ putSpaceDocument(space)
+ )
+ )
+ return true
+)
+
+def removeProjectFromSpaces(string projectId) boolean (
+ return removeProjectsFromSpaces([projectId])
+)
diff --git a/src/community/space-model.osl b/src/community/space-model.osl
new file mode 100644
index 0000000..b0d2aa0
--- /dev/null
+++ b/src/community/space-model.osl
@@ -0,0 +1,138 @@
+export {
+ stringArrayContainsUser
+}
+
+def stringArrayContainsUser(array users, string username) boolean (
+ string wanted = normalizeUsername(username)
+ for i users.len (
+ if normalizeUsername(users[i].toStr()) == wanted (
+ return true
+ )
+ )
+ return false
+)
+
+def removeUser(array users, string username) array (
+ string wanted = normalizeUsername(username)
+ array next = []
+ for i users.len (
+ if normalizeUsername(users[i].toStr()) != wanted (
+ next.append(users[i])
+ )
+ )
+ return next
+)
+
+def validSpaceKind(string kind) boolean (
+ return (kind == "studio") or (kind == "collection") or (kind == "challenge")
+)
+
+def validSpaceVisibility(string visibility) boolean (
+ return (visibility == "public") or (visibility == "unlisted") or (visibility == "private")
+)
+
+def spaceManagers(object space) array (
+ if space.contains("managers") (
+ return space["managers"].assertElse(array, [])
+ )
+ return []
+)
+
+def spaceCuratorInvites(object space) array (
+ if space.contains("curatorInvites") (
+ return space["curatorInvites"].assertElse(array, [])
+ )
+ return []
+)
+
+def spaceLikes(object space) array (
+ if space.contains("likes") (
+ return space["likes"].assertElse(array, [])
+ )
+ return []
+)
+
+def spaceBrokenHearts(object space) array (
+ if space.contains("brokenHearts") (
+ return space["brokenHearts"].assertElse(array, [])
+ )
+ return []
+)
+
+def spaceProjectIds(object space) array (
+ if space.contains("projects") and space.projects != null (
+ return space.projects.assertElse(array, [])
+ )
+ return []
+)
+
+def spaceFollowers(object space) array (
+ if space.contains("followers") and space.followers != null (
+ return space.followers.assertElse(array, [])
+ )
+ return []
+)
+
+def spaceCommentKey(string id) string (
+ return "space-" ++ id
+)
+
+def spaceThumbnailPath(string id) string (
+ return thumbnailsDir ++ "space-" ++ id ++ ".png"
+)
+
+def spaceThumbnailUrl(object space) string (
+ string path = spaceThumbnailPath(space["_id"].toStr())
+ if !fs.exists(path) (
+ return ""
+ )
+ return appURL ++ "/thumbnails/space-" ++ space["_id"].toStr() ++ ".png?t=" ++ space.edited.toStr()
+)
+
+def isSpaceOwner(object space,*serve.Context c) boolean (
+ string ownerId = ""
+ if space.contains("ownerId") and space.ownerId != null (
+ ownerId = space.ownerId.toStr()
+ )
+ return contextBool(c, "isAdmin") or identityMatches(ownerId, space.owner.toStr(), contextString(c, "userId"), contextString(c, "username"))
+)
+
+def searchEntryOwnedByContext(object project,*serve.Context c) boolean (
+ string ownerId = ""
+ if project.contains("ownerId") and project.ownerId != null (
+ ownerId = project.ownerId.toStr()
+ )
+ return identityMatches(ownerId, project.owner.toStr(), contextString(c, "userId"), contextString(c, "username"))
+)
+
+def isSpaceInvitee(object space,*serve.Context c) boolean (
+ array invites = spaceCuratorInvites(space)
+ for i invites.len (
+ object invite = invites[i].assert(object)
+ if normalizeUsername(invite.username.toStr()) == normalizeUsername(contextString(c, "username")) (
+ return true
+ )
+ )
+ return false
+)
+
+def canManageSpace(object space,*serve.Context c) boolean (
+ if contextBool(c, "isAdmin") (
+ return true
+ )
+ string username = contextString(c, "username")
+ if isSpaceOwner(space, c) (
+ return true
+ )
+ if canManageGroupContent(space, c) (
+ return true
+ )
+ return stringArrayContainsUser(spaceManagers(space), username)
+)
+
+def canViewSpace(object space,*serve.Context c) boolean (
+ if space.visibility.toStr() == "public" or space.visibility.toStr() == "unlisted" (
+ return true
+ )
+ return canManageSpace(space, c) or isSpaceInvitee(space, c)
+)
diff --git a/src/community/space-responses.osl b/src/community/space-responses.osl
new file mode 100644
index 0000000..d18f5c6
--- /dev/null
+++ b/src/community/space-responses.osl
@@ -0,0 +1,152 @@
+def spaceTotalPlaytime(array ids) number (
+ number total = 0
+ for i ids.len (
+ total += projectTotalPlaytime(ids[i].toStr())
+ )
+ return total
+)
+
+def spaceResponse(object space,*serve.Context c) object (
+ if !space.contains("commentCount") (
+ space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len
+ putSpaceDocument(space)
+ )
+ array projects = []
+ array ids = spaceProjectIds(space)
+ if space.kind.toStr() == "challenge" (
+ projects = challengeProjects(space, c)
+ ) else (
+ for i ids.len (
+ auto loaded = loadProject(ids[i].toStr())
+ if loaded.ok == true (
+ object project = loaded.project.assert(object)
+ if project.shared == true or projectVisibility(project) == "unlisted" or canManageSpace(space, c) (
+ projects.append(indexEntryFromProject(project))
+ )
+ )
+ )
+ projects = projectsVisibleTo(projects, c)
+ )
+ object response = space
+ response.projects = projects
+ array visibleIds = []
+ for i projects.len (
+ object visibleProject = projects[i].assert(object)
+ visibleIds.append(visibleProject.id)
+ )
+ response.projectIds = if canManageSpace(space, c) then ids else visibleIds
+ response.canManage = canManageSpace(space, c)
+ response.isOwner = isSpaceOwner(space, c)
+ response.following = stringArrayContainsUser(spaceFollowers(space), contextString(c, "username"))
+ response.followerCount = spaceFollowers(space).len
+ response.invited = isSpaceInvitee(space, c)
+ array likes = spaceLikes(space)
+ array brokenHearts = spaceBrokenHearts(space)
+ response.likeCount = likes.len
+ response.brokenHeartCount = brokenHearts.len
+ response.commentCount = space.commentCount
+ response.myReaction = ""
+ if stringArrayContainsUser(likes, contextString(c, "username")) (
+ response.myReaction = "heart"
+ ) else if stringArrayContainsUser(brokenHearts, contextString(c, "username")) (
+ response.myReaction = "brokenheart"
+ )
+ response.likes = []
+ response.brokenHearts = []
+ response.curatorInvites = []
+ response.thumbnailUrl = spaceThumbnailUrl(space)
+ if space.kind.toStr() != "challenge" (
+ response.totalPlaytimeMs = spaceTotalPlaytime(ids)
+ )
+ if space.kind.toStr() == "challenge" (
+ if !response.contains("theme") (
+ response.theme = ""
+ )
+ if !response.contains("rules") (
+ response.rules = ""
+ )
+ if !response.contains("judgingEndsAt") (
+ response.judgingEndsAt = space.endsAt.toNum() + 604800000
+ )
+ response.criteria = challengeCriteria(space)
+ if !response.contains("communityVoting") (
+ response.communityVoting = false
+ )
+ response.votingMode = challengeVotingMode(space)
+ response.winner = challengeWinner(space, projects)
+ if !response.contains("resultsPublishedAt") (
+ response.resultsPublishedAt = 0
+ )
+ response.phase = challengePhase(space)
+ response.isJudge = isChallengeJudge(space, contextString(c, "username"))
+ response.judgeInvited = isChallengeJudgeInvitee(space, contextString(c, "username"))
+ response.judgeCount = challengeJudges(space).len
+ response.participantCount = challengeArray(space, "participants").len
+ response.joined = stringArrayContainsUser(challengeArray(space, "participants"), contextString(c, "username"))
+ response.judges = challengeJudges(space)
+ response.judgeInvites = []
+ response.scores = []
+ response.audienceVotes = []
+ response.participants = []
+ )
+ return response
+)
+
+def spaceListResponse(object space,*serve.Context c) object (
+ array ids = spaceProjectIds(space)
+ array followers = spaceFollowers(space)
+ string thumbnailURL = spaceThumbnailUrl(space)
+ number projectCount = 0
+ boolean canManage = canManageSpace(space, c)
+ for i ids.len (
+ object project = (projectSearchDocs.findById(ids[i].toStr()) ?? {})
+ boolean hidden = projectHiddenFrom(project, c)
+ if project.len > 0 and !hidden and (project.shared == true or project.visibility.toStr() == "unlisted" or canManage) (
+ projectCount += 1
+ if thumbnailURL == "" and project.contains("thumbUrl") and thumbnailIsPublic(projectVisibility(project), false) (
+ thumbnailURL = project.thumbUrl.toStr()
+ )
+ )
+ )
+ object response = {
+ _id: space["_id"],
+ title: space.title,
+ description: space.description,
+ kind: space.kind,
+ visibility: space.visibility,
+ owner: space.owner,
+ groupTag: groupTagOf(space),
+ edited: space.edited,
+ startsAt: space.startsAt,
+ endsAt: space.endsAt,
+ openSubmissions: space.openSubmissions,
+ projects: [],
+ projectCount: projectCount,
+ followers: [],
+ followerCount: followers.len,
+ thumbnailUrl: thumbnailURL,
+ canManage: canManage,
+ isOwner: isSpaceOwner(space, c),
+ following: stringArrayContainsUser(followers, contextString(c, "username")),
+ invited: isSpaceInvitee(space, c)
+ }
+ if space.kind.toStr() == "challenge" (
+ response.participantCount = challengeArray(space, "participants").len
+ ) else (
+ response.totalPlaytimeMs = spaceTotalPlaytime(ids)
+ )
+ return response
+)
+
+def spaceManagementResponse(object space,*serve.Context c) object (
+ array invites = spaceCuratorInvites(space)
+ array judgeInvites = challengeArray(space, "judgeInvites")
+ array participants = challengeArray(space, "participants")
+ object response = spaceResponse(space, c)
+ response.curatorInvites = invites
+ if space.kind.toStr() == "challenge" (
+ response.judgeInvites = judgeInvites
+ response.participants = participants
+ )
+ return response
+)
diff --git a/src/community/spaces.osl b/src/community/spaces.osl
new file mode 100644
index 0000000..1731725
--- /dev/null
+++ b/src/community/spaces.osl
@@ -0,0 +1,737 @@
+def handleListSpaces(*serve.Context c) (
+ string kind = (c.query("kind") ?? "").toLower().strip()
+ string q = (c.query("q") ?? "").toLower().strip()
+ number limit = clampNumber(c.queryInt("limit", 24), 1, 100)
+ number offset = c.queryInt("offset", 0)
+ number startsBefore = c.queryInt("startsBefore", 0)
+ number endsAfter = c.queryInt("endsAfter", 0)
+ number resultsAfter = c.queryInt("resultsAfter", 0)
+ array stored = []
+
+ if q == "" and kind == "challenge" and resultsAfter > 0 (
+ stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("resultsPublishedAt", ">=", resultsAfter).sort("resultsPublishedAt", "desc").limit(limit).all()
+ array spaces = []
+ for i stored.len (
+ object published = stored[i].assert(object)
+ object listed = spaceListResponse(published, c)
+ listed.resultsPublishedAt = published.resultsPublishedAt
+ listed.winner = challengeWinner(published, challengeProjects(published, c))
+ if listed.winner.len > 0 (
+ spaces.append(listed)
+ )
+ )
+ c.json(200, {ok: true, total: spaces.len, spaces: spaces})
+ return
+ )
+
+ if q == "" and kind == "challenge" and startsBefore > 0 and endsAfter > 0 (
+ number total = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("startsAt", "<=", startsBefore).where("endsAt", ">=", endsAfter).count()
+ offset = clampNumber(offset, 0, total)
+ stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("startsAt", "<=", startsBefore).where("endsAt", ">=", endsAfter).sort("startsAt", "asc").limit(offset + limit).all()
+ array page = paginate(stored, offset, limit)
+ array spaces = []
+ for i page.len (
+ spaces.append(spaceListResponse(page[i].assert(object), c))
+ )
+ c.json(200, {ok: true, total: total, spaces: spaces})
+ return
+ )
+
+ if q == "" (
+ number total = 0
+ if kind != "" (
+ total = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).count()
+ offset = clampNumber(offset, 0, total)
+ stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).sort("edited", "desc").limit(offset + limit).all()
+ ) else (
+ total = spaceDocs.where("visibility", "==", "public").count()
+ offset = clampNumber(offset, 0, total)
+ stored = spaceDocs.where("visibility", "==", "public").sort("edited", "desc").limit(offset + limit).all()
+ )
+ array page = paginate(stored, offset, limit)
+ array spaces = []
+ for i page.len (
+ spaces.append(spaceListResponse(page[i].assert(object), c))
+ )
+ c.json(200, {ok: true, total: total, spaces: spaces})
+ return
+ )
+
+ if kind != "" (
+ stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).sort("edited", "desc").all()
+ ) else (
+ stored = spaceDocs.where("visibility", "==", "public").sort("edited", "desc").all()
+ )
+ array matches = []
+ for i stored.len (
+ object space = stored[i].assert(object)
+ boolean include = space.visibility.toStr() == "public"
+ if include and kind != "" (
+ include = space.kind.toStr() == kind
+ )
+ if include and q != "" (
+ include = space.title.toStr().toLower().contains(q) or space.description.toStr().toLower().contains(q)
+ )
+ if include (
+ number score = spaceSearchScore(space.title.toStr(), space.description.toStr(), space.owner.toStr(), q)
+ matches.append({rank: searchRank(score, spaceFollowers(space).len), space: space})
+ )
+ )
+ matches.sortBy("rank", "descending")
+ number total = matches.len
+ offset = clampNumber(offset, 0, total)
+ array page = paginate(matches, offset, limit)
+ array spaces = []
+ for i page.len (
+ object row = page[i].assert(object)
+ spaces.append(spaceListResponse(row.space.assert(object), c))
+ )
+ c.json(200, {ok: true, total: total, spaces: spaces})
+)
+
+def handleGetSpace(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !canViewSpace(space, c) (
+ c.notFound("space not found")
+ return
+ )
+ c.json(200, {ok: true, space: spaceResponse(space, c)})
+)
+
+def handleCreateSpace(*serve.Context c) (
+ object body = requestJSON(c)
+ number ownedSpaces = spaceDocs.where("ownerId", "==", contextString(c, "userId")).count()
+ if !contextBool(c, "isAdmin") and ownedSpaces >= mistwarpBenefitsForUserId(contextString(c, "userId")).maxSpaces.toNum() (
+ c.json(413, {ok: false, error: "space limit reached; delete a space or upgrade your plan"})
+ return
+ )
+ string groupTag = ""
+ if body.contains("groupTag") and body.groupTag != null and body.groupTag.toStr().strip() != "" (
+ groupTag = normalizeGroupTag(body.groupTag.toStr())
+ if groupTag == "" (
+ c.badRequest("invalid group tag")
+ return
+ )
+ if !canManageGroupTag(groupTag, c) (
+ c.forbidden("you cannot create spaces for this group")
+ return
+ )
+ )
+ string title = body.title.toStr().strip()
+ string kind = body.kind.toStr().toLower().strip()
+ if title == "" or title.len > 100 (
+ c.badRequest("title must be between 1 and 100 characters")
+ return
+ )
+ if !validSpaceKind(kind) (
+ c.badRequest("invalid space type")
+ return
+ )
+ string visibility = "public"
+ if body.contains("visibility") (
+ visibility = body.visibility.toStr().toLower()
+ )
+ if !validSpaceVisibility(visibility) (
+ c.badRequest("invalid visibility")
+ return
+ )
+ string description = ""
+ if body.contains("description") (
+ description = body.description.toStr().strip()
+ )
+ if description.len > 5000 (
+ c.badRequest("description is too long")
+ return
+ )
+ number startsAt = 0
+ number endsAt = 0
+ if body.contains("startsAt") (
+ startsAt = body.startsAt.toNum()
+ )
+ if body.contains("endsAt") (
+ endsAt = body.endsAt.toNum()
+ )
+ if kind == "challenge" and (startsAt <= 0 or endsAt <= startsAt) (
+ c.badRequest("challenge dates must be in order")
+ return
+ )
+ string votingMode = "judges"
+ if body.contains("votingMode") (
+ votingMode = body.votingMode.toStr().toLower().strip()
+ )
+ if votingMode != "judges" and votingMode != "audience" (
+ c.badRequest("invalid voting mode")
+ return
+ )
+ string id = "s" ++ randomString(12)
+ object space = {
+ _id: id,
+ title: title,
+ description: description,
+ kind: kind,
+ visibility: visibility,
+ owner: contextString(c, "username"),
+ ownerId: contextString(c, "userId"),
+ groupTag: groupTag,
+ managers: [],
+ curatorInvites: [],
+ projects: [],
+ followers: [],
+ likes: [],
+ brokenHearts: [],
+ openSubmissions: body.contains("openSubmissions") and body.openSubmissions == true,
+ startsAt: startsAt,
+ endsAt: endsAt,
+ judgingEndsAt: endsAt + 604800000,
+ theme: "",
+ rules: "",
+ criteria: [{id: "overall", name: "Overall", description: "How strong is the entry as a whole?", weight: 1}],
+ judges: [],
+ judgeInvites: [],
+ scores: [],
+ audienceVotes: [],
+ submissions: [],
+ participants: [],
+ commentCount: 0,
+ communityVoting: false,
+ votingMode: votingMode,
+ resultsPublishedAt: 0,
+ created: timestamp,
+ edited: timestamp
+ }
+ putSpaceDocument(space)
+ c.json(201, {ok: true, space: spaceResponse(space, c)})
+)
+
+def handleUpdateSpace(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ if !canManageSpace(space, c) (
+ c.forbidden("you cannot manage this space")
+ return
+ )
+ object body = requestJSON(c)
+ if body.contains("owner") (
+ boolean canTransfer = isSpaceOwner(space, c) or canManageGroupContent(space, c)
+ if !canTransfer (
+ c.forbidden("you cannot transfer this space")
+ return
+ )
+ string requestedOwner = body.owner.toStr().strip()
+ string nextOwnerId = discoverUserIdentity(requestedOwner)
+ if nextOwnerId == "" (
+ c.badRequest("Rotur user not found")
+ return
+ )
+ string nextOwner = usernameForId(nextOwnerId)
+ if nextOwner == "" (
+ c.badRequest("Rotur user not found")
+ return
+ )
+ number nextOwnerLimit = mistwarpBenefitsForUserId(nextOwnerId).maxSpaces.toNum()
+ if !identityMatches((space.ownerId ?? "").toStr(), space.owner.toStr(), nextOwnerId, nextOwner) and spaceDocs.where("ownerId", "==", nextOwnerId).count() >= nextOwnerLimit (
+ c.badRequest("the new owner has reached their space limit")
+ return
+ )
+ space.owner = nextOwner
+ space.ownerId = nextOwnerId
+ if !body.contains("keepGroup") or body.keepGroup != true (
+ space.groupTag = ""
+ )
+ )
+ if body.contains("groupTag") (
+ string nextGroupTag = normalizeGroupTag(body.groupTag.toStr())
+ if body.groupTag.toStr().strip() == "" (
+ nextGroupTag = ""
+ ) else if nextGroupTag == "" (
+ c.badRequest("invalid group tag")
+ return
+ ) else if !canManageGroupTag(nextGroupTag, c) (
+ c.forbidden("you cannot move this space into that group")
+ return
+ )
+ space.groupTag = nextGroupTag
+ )
+ if body.contains("title") (
+ string title = body.title.toStr().strip()
+ if title == "" or title.len > 100 (
+ c.badRequest("invalid title")
+ return
+ )
+ space.title = title
+ )
+ if body.contains("description") (
+ string description = body.description.toStr().strip()
+ if description.len > 5000 (
+ c.badRequest("description is too long")
+ return
+ )
+ space.description = description
+ )
+ if body.contains("visibility") and validSpaceVisibility(body.visibility.toStr()) (
+ space.visibility = body.visibility
+ )
+ if body.contains("openSubmissions") (
+ space.openSubmissions = body.openSubmissions == true
+ )
+ if body.contains("startsAt") (
+ space.startsAt = body.startsAt.toNum()
+ )
+ if body.contains("endsAt") (
+ space.endsAt = body.endsAt.toNum()
+ )
+ if body.contains("judgingEndsAt") (
+ space.judgingEndsAt = body.judgingEndsAt.toNum()
+ )
+ if body.contains("theme") (
+ string theme = body.theme.toStr().strip()
+ if theme.len > 200 (
+ c.badRequest("theme is too long")
+ return
+ )
+ space.theme = theme
+ )
+ if body.contains("rules") (
+ string rules = body.rules.toStr().strip()
+ if rules.len > 10000 (
+ c.badRequest("rules are too long")
+ return
+ )
+ space.rules = rules
+ )
+ if body.contains("communityVoting") (
+ space.communityVoting = body.communityVoting == true
+ )
+ if body.contains("votingMode") and space.kind.toStr() == "challenge" (
+ string votingMode = body.votingMode.toStr().toLower().strip()
+ if votingMode != "judges" and votingMode != "audience" (
+ c.badRequest("invalid voting mode")
+ return
+ )
+ if votingMode != challengeVotingMode(space) and challengePhase(space) == "results" (
+ c.badRequest("the voting mode cannot change after results are published")
+ return
+ )
+ space.votingMode = votingMode
+ )
+ if body.contains("criteria") and space.kind.toStr() == "challenge" (
+ if challengeArray(space, "scores").len > 0 (
+ c.badRequest("judging criteria cannot change after scoring begins")
+ return
+ )
+ array rawCriteria = body.criteria.assertElse(array, [])
+ array criteria = []
+ for i rawCriteria.len (
+ object rawCriterion = rawCriteria[i].assert(object)
+ string name = rawCriterion.name.toStr().strip()
+ string description = rawCriterion.description.toStr().strip()
+ if name != "" and name.len <= 60 and description.len <= 300 and criteria.len < 8 (
+ string criterionId = rawCriterion.id.toStr().strip()
+ if criterionId == "" (
+ criterionId = "jc" ++ randomString(8)
+ )
+ criteria.append({id: criterionId, name: name, description: description, weight: clampNumber(rawCriterion.weight.toNum(), 1, 5)})
+ )
+ )
+ if criteria.len == 0 (
+ c.badRequest("add at least one judging criterion")
+ return
+ )
+ space.criteria = criteria
+ )
+ if space.kind.toStr() == "challenge" and (space.startsAt.toNum() <= 0 or space.endsAt.toNum() <= space.startsAt.toNum() or space.judgingEndsAt.toNum() <= space.endsAt.toNum()) (
+ c.badRequest("challenge dates must be in order")
+ return
+ )
+ space.edited = timestamp
+ putSpaceDocument(space)
+ c.json(200, {ok: true, space: spaceResponse(space, c)})
+)
+
+def handleDeleteSpace(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ if !isSpaceOwner(space, c) (
+ c.forbidden("only the owner can delete this space")
+ return
+ )
+ void spaceDocs.deleteOne({_id: space["_id"]})
+ deleteSpaceMemberships(space["_id"].toStr())
+ void fs.remove(commentsDir ++ spaceCommentKey(space["_id"].toStr()) ++ ".json")
+ void fs.remove(commentsDir ++ spaceCommentKey(space["_id"].toStr()) ++ ".jsonl")
+ if fs.exists(spaceThumbnailPath(space["_id"].toStr())) (
+ void fs.remove(spaceThumbnailPath(space["_id"].toStr()))
+ )
+ c.json(200, {ok: true})
+)
+
+def handleSetSpaceThumbnail(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ if space.kind.toStr() != "studio" or !canManageSpace(space, c) (
+ c.forbidden("you cannot change this studio thumbnail")
+ return
+ )
+ auto fileResult = c.formFile("thumbnail")
+ if fileResult.isErr() (
+ c.badRequest("missing thumbnail file")
+ return
+ )
+ object thumbnail = fileResult.unwrap()
+ if thumbnail.size.toNum() > maxThumbBytes (
+ c.badRequest("thumbnail too large")
+ return
+ )
+ if !fs.writeFileBytes(spaceThumbnailPath(space["_id"].toStr()), thumbnail.data) (
+ c.internalError("thumbnail save failed")
+ return
+ )
+ space.edited = timestamp
+ putSpaceDocument(space)
+ c.json(200, {ok: true, thumbnailUrl: spaceThumbnailUrl(space)})
+)
+
+def handleAddSpaceProject(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ object project = (projectSearchDocs.findById(c.param("project")) ?? {})
+ if space.len == 0 or project.len == 0 (
+ c.notFound("space or project not found")
+ return
+ )
+ boolean projectVisible = project.shared == true or project.visibility.toStr() == "unlisted"
+ if !projectVisible (
+ c.forbidden("only shared or unlisted projects can be added")
+ return
+ )
+ boolean allowed = canManageSpace(space, c)
+ boolean ownsProject = searchEntryOwnedByContext(project, c)
+ boolean participantCanSubmit = space.openSubmissions == true and ownsProject
+ if space.kind.toStr() == "challenge" (
+ participantCanSubmit = participantCanSubmit and challengePhase(space) == "submissions"
+ )
+ allowed = allowed or participantCanSubmit
+ if !allowed (
+ c.forbidden("submissions are closed")
+ return
+ )
+ array ids = spaceProjectIds(space)
+ if !ids.contains(project.id.toStr()) (
+ ids.append(project.id.toStr())
+ space.projects = ids
+ if space.kind.toStr() == "challenge" (
+ array submissions = challengeArray(space, "submissions")
+ submissions.append({projectId: project.id, owner: project.owner, submittedAt: timestamp})
+ space.submissions = submissions
+ array participants = challengeArray(space, "participants")
+ if !stringArrayContainsUser(participants, project.owner.toStr()) (
+ participants.append(project.owner)
+ )
+ space.participants = participants
+ )
+ space.edited = timestamp
+ putSpaceDocument(space)
+ addNotification(space.owner.toStr(), contextString(c, "username"), "space_project", {spaceId: space["_id"], spaceTitle: space.title, projectId: project.id, projectTitle: project.title})
+ )
+ c.json(200, {ok: true, space: spaceResponse(space, c)})
+)
+
+def handleRemoveSpaceProject(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ object project = (projectSearchDocs.findById(c.param("project")) ?? {})
+ boolean allowed = canManageSpace(space, c)
+ if project.len > 0 (
+ allowed = allowed or searchEntryOwnedByContext(project, c)
+ )
+ if !allowed (
+ c.forbidden("you cannot remove this project")
+ return
+ )
+ space.projects = removeString(spaceProjectIds(space), c.param("project"))
+ if space.kind.toStr() == "challenge" (
+ array submissions = challengeArray(space, "submissions")
+ array nextSubmissions = []
+ for i submissions.len (
+ object submission = submissions[i].assert(object)
+ if submission.projectId.toStr() != c.param("project") (
+ nextSubmissions.append(submission)
+ )
+ )
+ space.submissions = nextSubmissions
+ )
+ space.edited = timestamp
+ putSpaceDocument(space)
+ c.json(200, {ok: true, space: spaceResponse(space, c)})
+)
+
+def handleFollowSpace(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !canViewSpace(space, c) (
+ c.notFound("space not found")
+ return
+ )
+ array followers = spaceFollowers(space)
+ if !stringArrayContainsUser(followers, contextString(c, "username")) (
+ followers.append(contextString(c, "username"))
+ space.followers = followers
+ putSpaceDocument(space)
+ )
+ c.json(200, {ok: true, following: true, followers: followers.len})
+)
+
+def handleUnfollowSpace(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ array followers = spaceFollowers(space)
+ if stringArrayContainsUser(followers, contextString(c, "username")) (
+ followers = removeUser(followers, contextString(c, "username"))
+ space.followers = followers
+ putSpaceDocument(space)
+ )
+ c.json(200, {ok: true, following: false, followers: followers.len})
+)
+
+def handleMySpaces(*serve.Context c) (
+ string userKey = normalizeUsername(contextString(c, "username"))
+ array memberships = spaceMemberDocs.where("userKey", "==", userKey).all()
+ array stored = []
+ object seen = {}
+ for i memberships.len (
+ object membership = memberships[i].assert(object)
+ object space = (spaceDocs.findById(membership.spaceId.toStr()) ?? {})
+ if space.len > 0 (
+ seen[space["_id"].toStr()] = true
+ stored.append(space)
+ )
+ )
+ array allSpaces = spaceDocs.all()
+ for i allSpaces.len (
+ object space = allSpaces[i].assert(object)
+ string spaceId = space["_id"].toStr()
+ if !seen.contains(spaceId) (
+ boolean include = isSpaceOwner(space, c)
+ if !include and groupTagOf(space) != "" (
+ include = canManageGroupContent(space, c)
+ )
+ if include (
+ seen[spaceId] = true
+ stored.append(space)
+ )
+ )
+ )
+ stored.sortBy("edited", "descending")
+ array spaces = []
+ for i stored.len (
+ object space = stored[i].assert(object)
+ spaces.append(spaceListResponse(space, c))
+ )
+ c.json(200, {ok: true, spaces: spaces})
+)
+
+def handleGetSpaceManagement(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ if !canManageSpace(space, c) (
+ c.forbidden("you cannot manage this space")
+ return
+ )
+ c.json(200, {ok: true, space: spaceManagementResponse(space, c)})
+)
+
+def handleInviteSpaceCurator(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ if !isSpaceOwner(space, c) (
+ c.forbidden("only the owner can invite curators")
+ return
+ )
+ string username = requestJSON(c).username.toStr().strip()
+ string usernameKey = normalizeUsername(username)
+ if usernameKey == "" or username.len > 20 or usernameKey == normalizeUsername(space.owner.toStr()) (
+ c.badRequest("choose another user")
+ return
+ )
+ object profile = (profileSearchDocs.findById(usernameKey) ?? {})
+ if profile.len == 0 (
+ c.notFound("user not found")
+ return
+ )
+ if stringArrayContainsUser(spaceManagers(space), username) (
+ c.badRequest("this user is already a curator")
+ return
+ )
+ array invites = spaceCuratorInvites(space)
+ for i invites.len (
+ object existing = invites[i].assert(object)
+ if normalizeUsername(existing.username.toStr()) == usernameKey (
+ c.badRequest("this user already has an invitation")
+ return
+ )
+ )
+ object invite = {username: profile.username, invitedBy: contextString(c, "username"), created: timestamp}
+ invites.append(invite)
+ space.curatorInvites = invites
+ space.edited = timestamp
+ putSpaceDocument(space)
+ addNotification(profile.username.toStr(), contextString(c, "username"), "space_curator_invite", {spaceId: space["_id"], spaceTitle: space.title})
+ c.json(201, {ok: true, invitation: invite})
+)
+
+def handleRespondSpaceCuratorInvite(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ string username = contextString(c, "username")
+ array invites = spaceCuratorInvites(space)
+ array remaining = []
+ boolean found = false
+ for i invites.len (
+ object invite = invites[i].assert(object)
+ if normalizeUsername(invite.username.toStr()) == normalizeUsername(username) (
+ found = true
+ ) else (
+ remaining.append(invite)
+ )
+ )
+ if !found (
+ c.notFound("invitation not found")
+ return
+ )
+ boolean accepted = requestJSON(c).accepted == true
+ if accepted (
+ array managers = spaceManagers(space)
+ if !stringArrayContainsUser(managers, username) (
+ managers.append(username)
+ )
+ space.managers = managers
+ )
+ space.curatorInvites = remaining
+ space.edited = timestamp
+ putSpaceDocument(space)
+ string notificationType = "space_curator_declined"
+ if accepted (
+ notificationType = "space_curator_accepted"
+ )
+ addNotification(space.owner.toStr(), username, notificationType, {spaceId: space["_id"], spaceTitle: space.title})
+ c.json(200, {ok: true, accepted: accepted})
+)
+
+def handleRemoveSpaceCurator(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ if !isSpaceOwner(space, c) (
+ c.forbidden("only the owner can remove curators")
+ return
+ )
+ string username = c.param("username")
+ array managers = spaceManagers(space)
+ if stringArrayContainsUser(managers, username) (
+ space.managers = removeUser(managers, username)
+ space.edited = timestamp
+ putSpaceDocument(space)
+ addNotification(username, contextString(c, "username"), "space_curator_removed", {spaceId: space["_id"], spaceTitle: space.title})
+ )
+ c.json(200, {ok: true})
+)
+
+def handleCancelSpaceCuratorInvite(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 (
+ c.notFound("space not found")
+ return
+ )
+ if !isSpaceOwner(space, c) (
+ c.forbidden("only the owner can cancel invitations")
+ return
+ )
+ string username = c.param("username")
+ array remaining = []
+ array invites = spaceCuratorInvites(space)
+ boolean found = false
+ for i invites.len (
+ object invite = invites[i].assert(object)
+ if normalizeUsername(invite.username.toStr()) != normalizeUsername(username) (
+ remaining.append(invite)
+ ) else (
+ found = true
+ )
+ )
+ if found (
+ space.curatorInvites = remaining
+ space.edited = timestamp
+ putSpaceDocument(space)
+ )
+ c.json(200, {ok: true})
+)
+
+def handleReactSpace(*serve.Context c) (
+ string lockName = "space-reactions:" ++ c.param("id")
+ sync.lock(lockName)
+ handleReactSpaceLocked(c)
+ sync.unlock(lockName)
+)
+
+def handleReactSpaceLocked(*serve.Context c) (
+ object space = (spaceDocs.findById(c.param("id")) ?? {})
+ if space.len == 0 or !canViewSpace(space, c) (
+ c.notFound("space not found")
+ return
+ )
+ string reactionType = requestJSON(c).type.toStr()
+ if !isValidReaction(reactionType) (
+ c.badRequest("invalid reaction")
+ return
+ )
+ string username = contextString(c, "username")
+ array likes = spaceLikes(space)
+ array brokenHearts = spaceBrokenHearts(space)
+ number previousLikes = likes.len
+ boolean hadReaction = false
+ if reactionType == "heart" (
+ hadReaction = stringArrayContainsUser(likes, username)
+ ) else (
+ hadReaction = stringArrayContainsUser(brokenHearts, username)
+ )
+ likes = removeUser(likes, username)
+ brokenHearts = removeUser(brokenHearts, username)
+ if !hadReaction and reactionType == "heart" (
+ likes.append(username)
+ ) else if !hadReaction and reactionType == "brokenheart" (
+ brokenHearts.append(username)
+ )
+ space.likes = likes
+ space.brokenHearts = brokenHearts
+ space.edited = timestamp
+ if !putSpaceDocument(space) (
+ c.internalError("could not save reaction")
+ return
+ )
+ notifyLikeMilestones(space.owner.toStr(), space.kind.toStr(), space["_id"].toStr(), previousLikes, likes.len, "/spaces/" ++ space["_id"].toStr(), {})
+ c.json(200, {ok: true, likeCount: likes.len, brokenHeartCount: brokenHearts.len, myReaction: if hadReaction then "" else reactionType})
+)