Skip to content

Commit

Permalink
More 2024 Milestone Features (#249)
Browse files Browse the repository at this point in the history
* resolve #189, you spin me right round

* resolves #250, partially addresses #135

* resolves #214, adds pagination to moderator log

* resolves #177, allows admin and tho in site ui with certain disable conditions

* resolves #135, adds about processing via api

* resolves #239, fixes pagination for joined fez and mod log

* resolves #251, brings back new und alertwords and makes UI less surprising

* The guts for #136

* more seamail muting

* resolves #136, finalized mute of seamail I think

* resolves #182, adds support for accessing mentions of twitarrteam and moderator

* #206 bumps up minimum Vapor version, adds notes for concurrency checking

* resolves #216, fixes seamail unread for special users (I think)

* add dummy schedule generator

* resolves #240, makes all lfgs not be perma-unread for moderators

* resolves #173, implements late day flip setting

* resolves #211, adds release calendar documentation

* #132 make the site ui identify itself

* comment

* resolves #183, enables one context button for lfg and seamail

* resolves #179, make open chat the default state

* resolves #176, adds links to profiles for seamail participants

* resolves #217, adds unread forum handlers

* resolves #168, site ui marks new forum read for creator

* resolves #199, correct forum counts after calendar update

* resolves #188, adds team field to user profile

* resolves #180, improves page titles for most common things

* resolves #181, improves user mention and hashtag detection

* resolves #210, moves preferredPronoun to UserHeader for wide consumability

* add username to profile page titles

* continues #231 add timeZoneID to FezData and ForumListdata to match EventData

add timezoneID to forumlistdata like fezdata and eventdata

* improve pronoun display by limiting to useful contexts

* improve pronoun display by limiting to useful contexts

* improve pronoun display with no displayname

* resolves #167, adds view forum posts by user to mods

* dedicated jobs for event schedule update

* address occasional redis connection timeouts
  • Loading branch information
cohoe committed Jan 18, 2024
1 parent 497f1e1 commit 7eea0b9
Show file tree
Hide file tree
Showing 77 changed files with 1,221 additions and 506 deletions.
1 change: 1 addition & 0 deletions .jazzy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ custom_categories:
- Development
- Roadmap
- Special Files
- Release Calendar

- name: Operations
children:
Expand Down
22 changes: 14 additions & 8 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,23 @@ let package = Package(
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.76.0"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.86.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),
.package(url: "https://github.com/vapor/redis.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/leaf.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/queues-redis-driver.git", from: "1.0.0"),
.package(url: "https://github.com/vapor/queues-redis-driver.git", from: "1.0.0"),
.package(url: "https://github.com/MrLotU/SwiftPrometheus.git", from: "1.0.0-alpha"),
.package(url: "https://github.com/johnsundell/ink.git", from: "0.1.0"),
],
targets: [
.systemLibrary(name: "gd", pkgConfig: "gdlib", providers: [.apt(["libgd-dev"]), .brew(["gd"]), .yum(["gd-devel"])]),
.systemLibrary(name: "jpeg", pkgConfig: "libjpeg", providers: [.apt(["libjpeg-dev"]), .brew(["jpeg-turbo"]), .yum(["libjpeg-turbo-devel"])]),
.target(name: "gdOverrides", dependencies: ["gd", "jpeg"], publicHeadersPath: "."),
.executableTarget(
name: "swiftarr",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.executableTarget(
name: "swiftarr",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
.product(name: "Redis", package: "redis"),
Expand All @@ -34,12 +34,18 @@ let package = Package(
"gd",
"jpeg",
"gdOverrides",
],
],
resources: [
.copy("Resources"),
.copy("seeds"),
]
),
// https://forums.swift.org/t/concurrency-checking-in-swift-packages-unsafeflags/61135/3
// swiftSettings: [
// // This one is more strict.
// .unsafeFlags(["-Xfrontend", "-strict-concurrency=complete"])
// .unsafeFlags(["-Xfrontend", "-warn-concurrency"])
// ]
),
.testTarget(name: "AppTests", dependencies: ["swiftarr"]),
],
cLanguageStandard: .c11
Expand Down
108 changes: 108 additions & 0 deletions Sources/swiftarr/Commands/ScheduleGenerator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Foundation
import Vapor

// Generates a dummy schedule for use in testing. Mostly written by ChatGPT.
func generateSchedule(startDate: Date, length: Int) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"

// Function to generate a random UID
func generateUID() -> String {
let characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return String((0..<32).map { _ in characters.randomElement()! })
}

// Print BEGIN:VCALENDAR only once before the first event
let calHeaderString = """
BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:JoCo Cruise Generated Schedule
X-WR-CALDESC:Event Calendar
METHOD:PUBLISH
CALSCALE:GREGORIAN
PRODID:-//Sched.com JoCo Cruise Generated Schedule//EN
X-WR-TIMEZONE:UTC
"""
print(calHeaderString)

for day in 0..<length {
// Calculate date for the current day
guard let currentDay = Calendar.current.date(byAdding: .day, value: day, to: startDate) else {
fatalError("Error in date calculation.")
}

// Generate hourly events between 10AM and 4PM
for hour in 10..<17 {
let startTime = String(format: "%02d:00", hour)
let endTime = String(format: "%02d:00", hour + 1)
let summary = "Day \(day + 1) at \(hour)00"
let description = "Event for Day \(day + 1) at \(hour)00"

printEvent(date: currentDay, startTime: startTime, endTime: endTime, summary: summary, description: description, location: "", categories: "", uid: generateUID())
}

// Red Team Dinner
printEvent(date: currentDay, startTime: "17:00", endTime: "19:00", summary: "Red Team Dinner", description: "Dinner for Red Team", location: "Dining Room", categories: "", uid: generateUID())

// Gold Team Show
printEvent(date: currentDay, startTime: "17:00", endTime: "19:00", summary: "Gold Team Show", description: "Show for Gold Team", location: "Main Stage", categories: "MAIN CONCERT", uid: generateUID())

// Gold Team Dinner
printEvent(date: currentDay, startTime: "19:30", endTime: "21:30", summary: "Gold Team Dinner", description: "Dinner for Gold Team", location: "Dining Room", categories: "", uid: generateUID())

// Red Team Show
printEvent(date: currentDay, startTime: "19:30", endTime: "21:30", summary: "Red Team Show", description: "Show for Red Team", location: "Main Stage", categories: "MAIN CONCERT", uid: generateUID())

// Morning Announcements
printEvent(date: currentDay, startTime: "10:00", endTime: "10:15", summary: "Morning Announcements", description: "Daily morning announcements", location: "", categories: "", uid: generateUID())

// Happy Hour
printEvent(date: currentDay, startTime: "16:00", endTime: "17:00", summary: "Happy Hour", description: "Happy Hour at Ocean Bar", location: "Ocean Bar", categories: "", uid: generateUID())
}

// Print END:VCALENDAR only once after the last event
print("END:VCALENDAR")
}

// Function to print events in ICS format
func printEvent(date: Date, startTime: String, endTime: String, summary: String, description: String, location: String, categories: String, uid: String) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
dateFormatter.timeZone = TimeZone(abbreviation: "UTC")

let dtstamp = dateFormatter.string(from: Date())
let dtstart = dateFormatter.string(from: date.addingTimeInterval(TimeInterval(startTime.prefix(2))! * 3600 + TimeInterval(startTime.suffix(2))! * 60))
let dtend = dateFormatter.string(from: date.addingTimeInterval(TimeInterval(endTime.prefix(2))! * 3600 + TimeInterval(endTime.suffix(2))! * 60))

let icsString = """
BEGIN:VEVENT
DTSTAMP:\(dtstamp)
DTSTART:\(dtstart)
DTEND:\(dtend)
SUMMARY:\(summary)
DESCRIPTION:\(description)
CATEGORIES:\(categories)
LOCATION:\(location)
SEQUENCE:0
UID:\(uid)
URL:https://twitarr.com/\(uid)
END:VEVENT
"""
print(icsString)
}

struct GenerateScheduleCommand: AsyncCommand {
struct Signature: CommandSignature { }

var help: String {
"""
Generates a dummy schedule based on the currently configured cruise start date and length. \
Intended to help craft test data during a non-standard sailing (like if you want to test \
what's going to happen right NowTM).
"""
}

func run(using context: CommandContext, signature: Signature) async throws {
generateSchedule(startDate: Settings.shared.cruiseStartDate(), length: Settings.shared.cruiseLengthInDays)
}
}
12 changes: 12 additions & 0 deletions Sources/swiftarr/Controllers/AdminController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ struct AdminController: APIRouteCollection {
ttAuthGroup.post("schedule", "update", "apply", use: scheduleChangeApplyHandler)
ttAuthGroup.get("schedule", "viewlog", use: scheduleChangeLogHandler)
ttAuthGroup.get("schedule", "viewlog", scheduleLogIDParam, use: scheduleGetLogEntryHandler)
ttAuthGroup.post("schedule", "reload", use: reloadScheduleHandler)

ttAuthGroup.get("regcodes", "stats", use: regCodeStatsHandler)
ttAuthGroup.get("regcodes", "find", searchStringParam, use: userForRegCodeHandler)
Expand Down Expand Up @@ -603,6 +604,17 @@ struct AdminController: APIRouteCollection {
return .ok
}


/// `GET /api/v3/admin/schedule/reload`
///
/// Trigger a reload of the Sched event schedule. Normally this happens automatically every hour.
///
/// - Returns: HTTP 200 OK.
func reloadScheduleHandler(_ req: Request) async throws -> HTTPStatus {
try await req.queue.dispatch(UpdateJob.self, .init())
return .ok
}

// MARK: - Utilities

// Gets the path where the uploaded schedule is kept. Only one schedule file can be in the hopper at a time.
Expand Down
15 changes: 8 additions & 7 deletions Sources/swiftarr/Controllers/AlertController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,7 @@ struct AlertController: APIRouteCollection {
}
else {
entry.forumMentionCount = value.int ?? 0
// @TODO disabling newForumMentionCount until we can integrate smarts along the lines of
// "If we're marking this post as read and it contains an alertword, then increment the
// _viewed key for that word."
// Otherwise we can never clear the "new" alertword notifications.
// entry.newForumMentionCount = max(0, entry.forumMentionCount - viewedCount)
entry.newForumMentionCount = 0
entry.newForumMentionCount = max(0, entry.forumMentionCount - viewedCount)
}
resultDict[word] = entry
}
Expand All @@ -187,19 +182,25 @@ struct AlertController: APIRouteCollection {
guard user.accessLevel.hasAccess(.moderator) else {
return nil
}
let userHash = try await req.redis.getUserHash(userID: user.userID)
let reportCount = try await Report.query(on: req.db).filter(\.$isClosed == false).filter(\.$actionGroup == nil)
.count()
let seamailHash = try await req.redis.hvals(in: "UnreadModSeamails-\(user.userID)", as: Int.self).get()
let moderatorUnreadCount = seamailHash.reduce(0) { $1 ?? 0 > 0 ? $0 + 1 : $0 }
let moderatorForumMentionCount = req.redis.getIntFromUserHash(userHash, field: .moderatorForumMention(0)) - req.redis.getIntFromUserHash(userHash, field: .moderatorForumMention(0), viewed: true)
var ttUnreadCount = 0
var ttForumMentionCount = 0
if user.accessLevel.hasAccess(.twitarrteam) {
let ttSeamailHash = try await req.redis.hvals(in: "UnreadTTSeamails-\(user.userID)", as: Int.self).get()
ttUnreadCount = ttSeamailHash.reduce(0) { $1 ?? 0 > 0 ? $0 + 1 : $0 }
ttForumMentionCount = req.redis.getIntFromUserHash(userHash, field: .twitarrTeamForumMention(0)) - req.redis.getIntFromUserHash(userHash, field: .twitarrTeamForumMention(0), viewed: true)
}
return UserNotificationData.ModeratorNotificationData(
openReportCount: reportCount,
newModeratorSeamailMessageCount: moderatorUnreadCount,
newTTSeamailMessageCount: ttUnreadCount
newTTSeamailMessageCount: ttUnreadCount,
newModeratorForumMentionCount: moderatorForumMentionCount,
newTTForumMentionCount: ttForumMentionCount
)
}

Expand Down
81 changes: 73 additions & 8 deletions Sources/swiftarr/Controllers/FezController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ struct FezController: APIRouteCollection {
tokenCacheAuthGroup.delete(fezIDParam, use: fezDeleteHandler)
tokenCacheAuthGroup.post(fezIDParam, "report", use: reportFezHandler)
tokenCacheAuthGroup.post("post", fezPostIDParam, "report", use: reportFezPostHandler)
tokenCacheAuthGroup.post(fezIDParam, "mute", use: muteAddHandler)
tokenCacheAuthGroup.delete(fezIDParam, "mute", use: muteRemoveHandler)
tokenCacheAuthGroup.post(fezIDParam, "mute", "remove", use: muteRemoveHandler)

}

Expand Down Expand Up @@ -227,7 +230,7 @@ struct FezController: APIRouteCollection {
query.fields(for: FezParticipant.self).fields(for: FriendlyFez.self).unique()
}
async let fezCount = try query.count()
async let pivots = query.sort(FriendlyFez.self, \.$updatedAt, .descending).range(urlQuery.calcRange()).all()
async let pivots = query.copy().sort(FezParticipant.self, \.$isMuted, .descending).sort(FriendlyFez.self, \.$updatedAt, .descending).range(urlQuery.calcRange()).all()
let fezDataArray = try await pivots.map { pivot -> FezData in
let fez = try pivot.joined(FriendlyFez.self)
return try buildFezData(from: fez, with: pivot, for: effectiveUser, on: req)
Expand Down Expand Up @@ -486,6 +489,9 @@ struct FezController: APIRouteCollection {
}
}
else if participantUserID != cacheUser.userID {
if let pivot = try await getUserPivot(fez: fez, userID: participantUserID, on: req.db), pivot.isMuted == true {
continue
}
participantNotifyList.append(participantUserID)
}
}
Expand Down Expand Up @@ -977,6 +983,60 @@ struct FezController: APIRouteCollection {
}
}
}

/// `POST /api/v3/fez/:fez_ID/mute`
///
/// Mute the specified `Fez` for the current user.
///
/// - Parameter fez_ID: In the URL path.
/// - Returns: 201 Created on success; 200 OK if already muted.
func muteAddHandler(_ req: Request) async throws -> HTTPStatus {
let cacheUser = try req.auth.require(UserCacheData.self)
let fez = try await FriendlyFez.findFromParameter(fezIDParam, on: req)
let effectiveUser = getEffectiveUser(user: cacheUser, req: req, fez: fez)
guard !cacheUser.getBlocks().contains(fez.$owner.id) else {
throw Abort(.notFound, reason: "this \(fez.fezType.lfgLabel) is not available")
}

guard let fezParticipant = try await fez.$participants.$pivots.query(on: req.db).filter(\.$user.$id == effectiveUser.userID).first() else {
throw Abort(.forbidden, reason: "user is not a member of this fez")
}

if fezParticipant.isMuted == true {
return .ok
}
fezParticipant.isMuted = true
try await fezParticipant.save(on: req.db)
return .created
}

/// `POST /api/v3/fez/:fez_ID/mute/remove`
/// `DELETE /api/v3/fez/:fez_ID/mute`
///
/// Unmute the specified `Fez` for the current user.
///
/// - Parameter fez_ID: In the URL path.
/// - Throws: 400 error if the forum was not muted.
/// - Returns: 204 No Content on success; 200 OK if already not muted.
func muteRemoveHandler(_ req: Request) async throws -> HTTPStatus {
let cacheUser = try req.auth.require(UserCacheData.self)
let fez = try await FriendlyFez.findFromParameter(fezIDParam, on: req)
let effectiveUser = getEffectiveUser(user: cacheUser, req: req, fez: fez)
guard !cacheUser.getBlocks().contains(fez.$owner.id) else {
throw Abort(.notFound, reason: "this \(fez.fezType.lfgLabel) is not available")
}

guard let fezParticipant = try await fez.$participants.$pivots.query(on: req.db).filter(\.$user.$id == effectiveUser.userID).first() else {
throw Abort(.forbidden, reason: "user is not a member of this fez")
}

if fezParticipant.isMuted != true {
return .ok
}
fezParticipant.isMuted = nil
try await fezParticipant.save(on: req.db)
return .noContent
}
}

// MARK: - Helper Functions
Expand Down Expand Up @@ -1022,12 +1082,20 @@ extension FezController {
participants = valids
waitingList = []
}

// https://github.com/jocosocial/swiftarr/issues/240
// Moderators can see postCount and readCount regardless of whether they've joined
// or not. If they have joined, they should get their personal pivot data. If they
// haven't joined, they shouldn't default to readCount=0 because then every LFG
// appears with unread messages that cannot be cleared.
let postCount = fez.postCount - (pivot?.hiddenCount ?? 0)
fezData.members = FezData.MembersOnlyData(
participants: participants,
waitingList: waitingList,
postCount: fez.postCount - (pivot?.hiddenCount ?? 0),
readCount: pivot?.readCount ?? 0,
posts: posts
postCount: postCount,
readCount: pivot?.readCount ?? postCount,
posts: posts,
isMuted: pivot?.isMuted ?? false
)
}
return fezData
Expand Down Expand Up @@ -1101,9 +1169,6 @@ extension FezController {
// user 'moderator' or user 'TwitarrTeam' is a member of the fez and the user has the appropriate access level.
//
func getEffectiveUser(user: UserCacheData, req: Request, fez: FriendlyFez) -> UserCacheData {
if fez.participantArray.contains(user.userID) {
return user
}
// If either of these 'special' users are fez members and the user has high enough access, we can see the
// members-only values of the fez as the 'special' user.
if user.accessLevel >= .twitarrteam, let ttUser = req.userCache.getUser(username: "TwitarrTeam"),
Expand All @@ -1116,7 +1181,7 @@ extension FezController {
{
return modUser
}
// User isn't a member of the fez, but they're still the effective user in this case.
// User is or is not a member of the fez. But they are themself and not anyone special.
return user
}
}
Loading

0 comments on commit 7eea0b9

Please sign in to comment.