Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Personal Events #311

Merged
merged 39 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
af831e5
start adding PersonalEvent models
cohoe Jul 27, 2024
cb4ad39
fix typo in unrelated file name
cohoe Jul 27, 2024
eab78a3
kill the pivot, add search index, finish main schema
cohoe Jul 27, 2024
32961a4
start adding api calls
cohoe Jul 27, 2024
03db4a8
create and delete work
cohoe Jul 27, 2024
11406dc
add single get and update handlers
cohoe Jul 27, 2024
9d3460f
Add user remove handler
cohoe Jul 27, 2024
d4d1255
add report handler, fix some lazy loading issues
cohoe Jul 27, 2024
9dde78b
add new socket struct
cohoe Jul 28, 2024
1884817
add users handler
cohoe Jul 28, 2024
3612532
add query params
cohoe Jul 28, 2024
eb3e9f1
fix a loading issue
cohoe Jul 31, 2024
88eacce
remove logging
cohoe Jul 31, 2024
b20d0e4
kinda
cohoe Aug 1, 2024
c7584e4
make it work, cleanup old functions
cohoe Aug 1, 2024
38c86f3
fixes #310, adds viewing and managing favorites to site UI
cohoe Aug 2, 2024
01d3181
change param name
cohoe Aug 2, 2024
b1f9290
broke regular usage by bad parameter check
cohoe Aug 2, 2024
607c9e5
fix participants getting event
cohoe Aug 3, 2024
87c4ee6
add ICS download for personal events
cohoe Aug 3, 2024
2d4d7b2
start enabling websocket notifications for personalevents
cohoe Aug 3, 2024
10742ce
notify on personal events
cohoe Aug 22, 2024
8f298a9
start adding moderation
cohoe Aug 22, 2024
189ab37
add participants
cohoe Aug 22, 2024
dbb2256
cleanup html, add buttons, make it work
cohoe Aug 23, 2024
255ea44
api changelist
cohoe Aug 23, 2024
e809b51
api changelist
cohoe Aug 23, 2024
1ce5a13
merge
cohoe Aug 23, 2024
7e11cc2
cleanup docs
cohoe Aug 23, 2024
e4fa9ab
Merge branch 'master' into personalevents
cohoe Aug 24, 2024
5247fc2
docs update
cohoe Aug 24, 2024
9d8f276
swift-format
cohoe Aug 28, 2024
66e3a6e
do the entire migration in one
cohoe Sep 1, 2024
6de10bc
add struct comments
cohoe Sep 1, 2024
ccc2f71
endTime restrictions and favoriting self
cohoe Sep 1, 2024
cab84ea
Merge remote-tracking branch 'origin/master' into personalevents
cohoe Sep 1, 2024
b26f216
hashing of PE UID
cohoe Sep 1, 2024
e0f586b
I was told I'd get +10 Swift Developer Swifty points
cohoe Sep 1, 2024
2ca3905
merge
cohoe Oct 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/swiftarr/Controllers/FezController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ struct FezController: APIRouteCollection {
let user = try req.auth.require(UserCacheData.self)
try user.guardCanCreateContent(customErrorString: "User cannot create LFGs/Seamails.")
// see `FezContentData.validations()`
let data = try ValidatingJSONDecoder().decode(FezContentData.self, fromBodyOf: req)
let data: FezContentData = try ValidatingJSONDecoder().decode(FezContentData.self, fromBodyOf: req)
var creator = user
if data.createdByTwitarrTeam == true {
guard user.accessLevel >= .twitarrteam else {
Expand Down
33 changes: 33 additions & 0 deletions Sources/swiftarr/Controllers/ModerationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ struct ModerationController: APIRouteCollection {
moderatorAuthGroup.post("microkaraoke", "snippet", mkSnippetIDParam, "delete", use: deleteSnippet)
moderatorAuthGroup.delete("microkaraoke", "snippet", mkSnippetIDParam, use: deleteSnippet)
moderatorAuthGroup.post("microkaraoke", "approve", mkSongIDParam, use: approveSong)

moderatorAuthGroup.get("personalevent", personalEventIDParam, use: personalEventModerationHandler)
challfry marked this conversation as resolved.
Show resolved Hide resolved
}

// MARK: - tokenAuthGroup Handlers (logged in)
Expand Down Expand Up @@ -871,4 +873,35 @@ struct ModerationController: APIRouteCollection {

return .ok
}

// MARK: PersonalEvent

/// `GET /api/v3/mod/personalevent/:eventID`
///
/// Return moderation data for a PersonalEvent.
func personalEventModerationHandler(_ req: Request) async throws -> PersonalEventModerationData {
guard let paramVal = req.parameters.get(personalEventIDParam.paramString), let eventID: UUID = UUID(paramVal) else {
throw Abort(.badRequest, reason: "Request parameter \(personalEventIDParam.paramString) is missing.")
}
guard let personalEvent = try await PersonalEvent.query(on: req.db).filter(\._$id == eventID).withDeleted().first() else {
throw Abort(.notFound, reason: "no value found for identifier '\(paramVal)'")
}
let reports = try await Report.query(on: req.db)
.filter(\.$reportType == .personalEvent)
.filter(\.$reportedID == paramVal)
.sort(\.$createdAt, .descending).all()

let ownerHeader = try req.userCache.getHeader(personalEvent.$owner.id)
let participantHeaders = try personalEvent.participantArray.map { try req.userCache.getHeader($0) }
let reportData = try reports.map { try ReportModerationData.init(req: req, report: $0) }
let personalEventData = try PersonalEventData(personalEvent, ownerHeader: ownerHeader, participantHeaders: participantHeaders)

let modData = PersonalEventModerationData(
personalEvent: personalEventData,
isDeleted: personalEvent.deletedAt != nil,
moderationStatus: personalEvent.moderationStatus,
reports: reportData
)
return modData
}
}
265 changes: 265 additions & 0 deletions Sources/swiftarr/Controllers/PersonalEventController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import Crypto
import Fluent
import FluentSQL
import Vapor

/// The collection of `/api/v3/personalevents/*` route endpoints and handler functions related
/// to events that are specific to individual users.

struct PersonalEventController: APIRouteCollection {

/// Required. Registers routes to the incoming router.
func registerRoutes(_ app: Application) throws {

// Convenience route group for all /api/v3/personalevents endpoints.
let personalEventRoutes = app.grouped("api", "v3", "personalevents")

// Endpoints available only when logged in.
let tokenAuthGroup = personalEventRoutes.tokenRoutes(feature: .personalevents)
tokenAuthGroup.get(use: personalEventsHandler)

tokenAuthGroup.post("create", use: personalEventCreateHandler)
tokenAuthGroup.get(personalEventIDParam, use: personalEventHandler)
tokenAuthGroup.post(personalEventIDParam, use: personalEventUpdateHandler)
tokenAuthGroup.post(personalEventIDParam, "update", use: personalEventUpdateHandler)
tokenAuthGroup.post(personalEventIDParam, "delete", use: personalEventDeleteHandler)
tokenAuthGroup.delete(personalEventIDParam, use: personalEventDeleteHandler)

tokenAuthGroup.post(personalEventIDParam, "user", userIDParam, "remove", use: personalEventUserRemoveHandler)
tokenAuthGroup.delete(personalEventIDParam, "user", userIDParam, use: personalEventUserRemoveHandler)

tokenAuthGroup.post(personalEventIDParam, "report", use: personalEventReportHandler)
}

// MARK: - tokenAuthGroup Handlers (logged in)
/// All handlers in this route group require a valid HTTP Bearer Authentication
/// header in the request.

/// `GET /api/v3/personalevents`
///
/// Retrieve the `PersonalEvent`s the user has access to, sorted by `.startTime`.
/// By default this returns all events that the user has created or was added to.
///
/// **URL Query Parameters:**
/// - ?cruiseday=INT Embarkation day is day 1, value should be less than or equal to `Settings.shared.cruiseLengthInDays`, which will be 8 for the 2022 cruise.
/// - ?search=STRING Returns events whose title or description contain the given string.
/// - ?owned=BOOLEAN Returns events only that the user has created. Mutually exclusive with joined.
/// - ?joined=BOOLEAN Returns events only that the user has joined. Mutually exclusive with owned.
///
/// - Returns: An array of `PersonalEeventData` containing the `PersonalEvent`s.
func personalEventsHandler(_ req: Request) async throws -> [PersonalEventData] {
let cacheUser = try req.auth.require(UserCacheData.self)
struct QueryOptions: Content {
var cruiseday: Int?
var search: String?
var owned: Bool?
var joined: Bool?
}
let options: QueryOptions = try req.query.decode(QueryOptions.self)
if let _ = options.owned, let _ = options.joined {
throw Abort(.badRequest, reason: "Cannot specify both parameters 'joined' and 'owned'.")
}
let particpantArrayFieldName = PersonalEvent().$participantArray.key.description

let query = PersonalEvent.query(on: req.db).sort(\.$startTime, .ascending)

if let _ = options.owned {
query.filter(\.$owner.$id == cacheUser.userID)
}
else if let _ = options.joined {
query.filter(.sql(unsafeRaw: "\'\(cacheUser.userID)\' = ANY(\"\(particpantArrayFieldName)\")"))
}
else {
query.group(.or) { group in
group.filter(\.$owner.$id == cacheUser.userID)
group.filter(.sql(unsafeRaw: "'\(cacheUser.userID)' = ANY(\"\(particpantArrayFieldName)\")"))
}
}

if let cruiseday = options.cruiseday {
let portCalendar = Settings.shared.getPortCalendar()
let cruiseStartDate = Settings.shared.cruiseStartDate()
// This is close to Events, but not quite.
// https://github.com/jocosocial/swiftarr/issues/230
let searchStartTime = portCalendar.date(byAdding: .day, value: cruiseday, to: cruiseStartDate)
let searchEndTime = portCalendar.date(byAdding: .day, value: cruiseday + 1, to: cruiseStartDate)
if let start = searchStartTime, let end = searchEndTime {
query.filter(\.$startTime >= start).filter(\.$startTime < end)
}
}

if var search = options.search {
// postgres "_" and "%" are wildcards, so escape for literals
search = search.replacingOccurrences(of: "_", with: "\\_")
search = search.replacingOccurrences(of: "%", with: "\\%")
search = search.trimmingCharacters(in: .whitespacesAndNewlines)
query.fullTextFilter(\.$title, search) // This is also getting description...
}

let events = try await query.all()
return try await buildPersonalEventDataList(events, on: req)
}

/// `POST /api/v3/personalevents/create`
///
/// Create a new PersonalEvent.
///
/// - Parameter requestBody: `PersonalEventContentData` payload in the HTTP body.
/// - Throws: 400 error if the supplied data does not validate.
/// - Returns: 201 Created; `PersonalEventData` containing the newly created event.
func personalEventCreateHandler(_ req: Request) async throws -> Response {
let cacheUser = try req.auth.require(UserCacheData.self)
let data: PersonalEventContentData = try ValidatingJSONDecoder()
.decode(PersonalEventContentData.self, fromBodyOf: req)

let favorites = try await UserFavorite.query(on: req.db).filter(\.$favorite.$id == cacheUser.userID).all()
let favoritesUserIDs = favorites.map({ $0.$user.id })
cohoe marked this conversation as resolved.
Show resolved Hide resolved
try data.participants.forEach { userID in
if !favoritesUserIDs.contains(userID) {
throw Abort(.forbidden, reason: "Cannot have a participant who has not favorited you.")
}
}

let personalEvent = PersonalEvent(data, cacheOwner: cacheUser)
try await personalEvent.save(on: req.db)
let personalEventData = try buildPersonalEventData(personalEvent, on: req)

// Return with 201 status
let response = Response(status: .created)
try response.content.encode(personalEventData)
return response
}

/// `GET /api/v3/personalevents/:eventID`
///
/// Get a single `PersonalEvent`.
///
/// - Throws: 403 error if you're not allowed.
/// - Returns: `PersonalEventData` containing the event.
func personalEventHandler(_ req: Request) async throws -> PersonalEventData {
let cacheUser = try req.auth.require(UserCacheData.self)
let personalEvent = try await PersonalEvent.findFromParameter(personalEventIDParam, on: req)
guard (
personalEvent.$owner.id == cacheUser.userID ||
cacheUser.accessLevel.hasAccess(.moderator) ||
personalEvent.participantArray.contains(cacheUser.userID)
) else {
throw Abort(.forbidden, reason: "You cannot access this personal event.")
}
return try buildPersonalEventData(personalEvent, on: req)
}

/// `POST /api/v3/personalevents/:eventID`
///
/// Updates an existing `PersonalEvent`.
/// Note: All fields in the supplied `PersonalEventContentData` must be filled, just as if the event
/// were being created from scratch.
///
/// - Parameter requestBody: `PersonalEventContentData` payload in the HTTP body.
/// - Throws: 400 error if the supplied data does not validate.
/// - Returns: `PersonalEventData` containing the updated event.
func personalEventUpdateHandler(_ req: Request) async throws -> PersonalEventData {
let cacheUser = try req.auth.require(UserCacheData.self)
let personalEvent = try await PersonalEvent.findFromParameter(personalEventIDParam, on: req)
let data: PersonalEventContentData = try ValidatingJSONDecoder()
.decode(PersonalEventContentData.self, fromBodyOf: req)

let favorites = try await UserFavorite.query(on: req.db).filter(\.$favorite.$id == cacheUser.userID).all()
let favoritesUserIDs = favorites.map { $0.$user.id }
try data.participants.forEach { userID in
if !favoritesUserIDs.contains(userID) {
throw Abort(.forbidden, reason: "Cannot have a participant who has not favorited you.")
}
}

personalEvent.title = data.title
personalEvent.description = data.description
personalEvent.startTime = data.startTime
personalEvent.endTime = data.endTime
personalEvent.location = data.location
personalEvent.participantArray = data.participants
try await personalEvent.save(on: req.db)

return try buildPersonalEventData(personalEvent, on: req)

}

/// `POST /api/v3/personalevents/:eventID/delete`
/// `DELETE /api/v3/personalevents/:eventID`
///
/// Deletes the given `PersonalEvent`.
///
/// - Parameter eventID: in URL path.
/// - Throws: 403 error if the user is not permitted to delete.
/// - Returns: 204 No Content on success.
func personalEventDeleteHandler(_ req: Request) async throws -> HTTPStatus {
let cacheUser = try req.auth.require(UserCacheData.self)
let personalEvent = try await PersonalEvent.findFromParameter(personalEventIDParam, on: req)
try cacheUser.guardCanModifyContent(personalEvent)
try await personalEvent.logIfModeratorAction(.delete, moderatorID: cacheUser.userID, on: req)
try await personalEvent.delete(on: req.db)
return .noContent
}

/// `POST /api/v3/personalevents/:eventID/user/:userID/delete`
/// `DELETE /api/v3/personalevents/:eventID/user/:userID`
///
/// Removes a `User` from the `PersonalEvent`.
/// Intended to be called by the `User` if they do not want to see this event.
///
/// - Parameter eventID: in URL path.
/// - Parameter userID: in the URL path.
/// - Throws: 403 error if the user is not permitted to delete.
/// - Returns: 204 No Content on success.
func personalEventUserRemoveHandler(_ req: Request) async throws -> HTTPStatus {
let cacheUser = try req.auth.require(UserCacheData.self)
let personalEvent = try await PersonalEvent.findFromParameter(personalEventIDParam, on: req)
let removeUser = try await User.findFromParameter(userIDParam, on: req)
guard
personalEvent.$owner.id == cacheUser.userID || personalEvent.participantArray.contains(cacheUser.userID)
|| cacheUser.accessLevel.hasAccess(.moderator)
else {
throw Abort(.forbidden, reason: "You cannot access this personal event.")
}
personalEvent.participantArray.removeAll { $0 == removeUser.id }
try await personalEvent.save(on: req.db)
try await personalEvent.logIfModeratorAction(.edit, moderatorID: cacheUser.userID, on: req)
return .noContent
}

/// `POST /api/v3/personalevents/:eventID/report`
///
/// Creates a `Report` regarding the specified `PersonalEvent`.
///
/// - Note: The accompanying report message is optional on the part of the submitting user,
/// but the `ReportData` is mandatory in order to allow one. If there is no message,
/// send an empty string in the `.message` field.
///
/// - Parameter eventID: in URL path, the PersonalEvent ID to report.
/// - Parameter requestBody: `ReportData`
/// - Returns: 201 Created on success.
func personalEventReportHandler(_ req: Request) async throws -> HTTPStatus {
let submitter = try req.auth.require(UserCacheData.self)
let data = try req.content.decode(ReportData.self)
let reportedEvent = try await PersonalEvent.findFromParameter(personalEventIDParam, on: req)
return try await reportedEvent.fileReport(submitter: submitter, submitterMessage: data.message, on: req)
}
}

// MARK: Utility Functions
extension PersonalEventController {
/// Builds a `PersonalEventData` from a `PersonalEvent`.
func buildPersonalEventData(_ personalEvent: PersonalEvent, on: Request) throws -> PersonalEventData {
let ownerHeader = try on.userCache.getHeader(personalEvent.$owner.id)
let participantHeaders = on.userCache.getHeaders(personalEvent.participantArray)
return try PersonalEventData(personalEvent, ownerHeader: ownerHeader, participantHeaders: participantHeaders)
}

/// Builds an array of `PersonalEventData` from the given `PersonalEvent`s.
func buildPersonalEventDataList(_ personalEvents: [PersonalEvent], on: Request) async throws -> [PersonalEventData]
{
return try personalEvents.map { event in
try buildPersonalEventData(event, on: on)
}
}
}
62 changes: 61 additions & 1 deletion Sources/swiftarr/Controllers/Structs/ControllerStructs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1568,11 +1568,14 @@ public struct ProfilePublicData: Content {
var dinnerTeam: DinnerTeam?
/// A UserNote owned by the visiting user, about the profile's user (see `UserNote`).
var note: String?
/// Whether the requesting user has favorited this user.
var isFavorite: Bool
}

extension ProfilePublicData {
init(user: User, note: String?, requesterAccessLevel: UserAccessLevel) throws {
init(user: User, note: String?, requesterAccessLevel: UserAccessLevel, requesterHasFavorite: Bool) throws {
self.header = try UserHeader(user: user)
self.isFavorite = requesterHasFavorite
if !user.moderationStatus.showsContent() && !requesterAccessLevel.hasAccess(.moderator) {
self.header.displayName = nil
self.header.userImage = nil
Expand Down Expand Up @@ -2313,3 +2316,60 @@ public struct HealthResponse: Content {
var reason: String = "OK"
var error: Bool = false
}

// MARK: Personal Events
///
/// Used to return a `PersonalEvent`'s data.
public struct PersonalEventData: Content {
var personalEventID: UUID
cohoe marked this conversation as resolved.
Show resolved Hide resolved
var title: String
var description: String?
var startTime: Date
var endTime: Date
var timeZone: String
var timeZoneID: String
var location: String?
var lastUpdateTime: Date
var owner: UserHeader
var participants: [UserHeader]
}

extension PersonalEventData {
init(_ personalEvent: PersonalEvent, ownerHeader: UserHeader, participantHeaders: [UserHeader]) throws {
let timeZoneChanges = Settings.shared.timeZoneChanges
self.personalEventID = try personalEvent.requireID()
self.title = personalEvent.title
self.description = personalEvent.description
self.startTime = timeZoneChanges.portTimeToDisplayTime(personalEvent.startTime)
self.endTime = timeZoneChanges.portTimeToDisplayTime(personalEvent.endTime)
self.timeZone = timeZoneChanges.abbrevAtTime(self.startTime)
self.timeZoneID = timeZoneChanges.tzAtTime(self.startTime).identifier
self.location = personalEvent.location
self.lastUpdateTime = personalEvent.updatedAt ?? Date()
self.owner = ownerHeader
self.participants = participantHeaders
}
}

public struct PersonalEventContentData: Content {
/// The title for the PersonalEvent.
var title: String
/// A description of the PersonalEvent.
var description: String?
/// The starting time for the PersonalEvent.
var startTime: Date
/// The ending time for the PersonalEvent.
var endTime: Date
/// The location for the PersonalEvent.
var location: String?
/// Users to invite to this PersonalEvent.
var participants: [UUID]
}

extension PersonalEventContentData: RCFValidatable {
func runValidations(using decoder: ValidatingDecoder) throws {
let tester = try decoder.validator(keyedBy: CodingKeys.self)
tester.validate(title.count >= 2, forKey: .title, or: "title field has a 2 character minimum")
tester.validate(title.count <= 100, forKey: .title, or: "title field has a 100 character limit")
}
}
Loading