diff --git a/Sources/App/Controllers/OrganizationController.swift b/Sources/App/Controllers/OrganizationController.swift index 86a5389..6908b08 100644 --- a/Sources/App/Controllers/OrganizationController.swift +++ b/Sources/App/Controllers/OrganizationController.swift @@ -2,6 +2,71 @@ import Foundation import Fluent import Vapor +enum OrganizationRoleDTO: String, Content { + case admin + case editor + case lurker +} + +struct OrganizationMemberDTO: Content { + + enum InvitationStatus: String, Content { + case invited + case joined + } + + var email: String + var role: OrganizationRoleDTO + var status: InvitationStatus +} + +struct OrganizationDTO: Content { + var id: UUID + var name: String + var apiKey: String? +} + +struct OrganizationUpdateDTO: Content, Validatable { + var name: String? + var resetApiKey: Bool? + var deleteApiKey: Bool? + + static func validations(_ validations: inout Validations) { + validations.add("name", as: String?.self, is: .nil || !.empty, required: false) + } +} + +extension Organization { + func toDTO() throws -> OrganizationDTO { + return .init(id: try requireID(), name: name, apiKey: apiKey) + } +} + +extension ProfileOrganizationRole.Role { + func toDTO() -> OrganizationRoleDTO { + switch self { + case .admin: + return .admin + case .editor: + return .editor + case .lurker: + return .lurker + } + } +} + +extension ProfileOrganizationRole { + func toDTO() throws -> OrganizationMemberDTO { + .init(email: profile.email, role: role.toDTO(), status: .joined) + } +} + +extension OrganizationInvite { + func toDTO() throws -> OrganizationMemberDTO { + .init(email: email, role: role.toDTO(), status: .invited) + } +} + extension Request { func organization(minRole: ProfileOrganizationRole.Role) async throws -> Organization { guard let organizationId = self.parameters.get("organizationID").flatMap({ UUID(uuidString: $0) }) else { @@ -35,59 +100,23 @@ extension Request { } } -enum OrganizationRoleDTO: String, Content { - case admin - case editor - case lurker -} - -struct OrganizationMemberDTO: Content { - var profile: ProfileLiteDTO - var role: OrganizationRoleDTO -} - -struct OrganizationDTO: Content { - var id: UUID - var name: String - var members: [OrganizationMemberDTO] -} - -extension Organization { - func toDTO() throws -> OrganizationDTO { - guard let id else { - throw Abort(.internalServerError, reason: "missing organization id") - } - - let members: [OrganizationMemberDTO] = try organizationRoles.map { role in - - let roleDTO: OrganizationRoleDTO - switch role.role { - case .admin: - roleDTO = .admin - case .editor: - roleDTO = .editor - case .lurker: - roleDTO = .lurker - } - - return try .init(profile: role.profile.toLiteDTO(), role: roleDTO) - } - - return .init(id: id, name: name, members: members) - } -} - struct OrganizationController: RouteCollection { func boot(routes: RoutesBuilder) throws { let organizations = routes.grouped("organization") organizations.get(use: index) organizations.post(use: create) organizations.group(":organizationID") { organization in + organization.get(use: get) organization.patch(use: patch) organization.delete(use: delete) - organization.group("members", ":profileID") { members in + + organization.group("members") { members in + members.get(use: listOrganizationMemberships) members.put(use: putOrganizationMembership) - members.delete(use: deleteOrganizationMembership) + members.post(use: putOrganizationMembership) + members.group(":memberEmail") { member in + member.delete(use: deleteOrganizationMembership) + } } } } @@ -95,7 +124,15 @@ struct OrganizationController: RouteCollection { func index(req: Request) async throws -> [OrganizationDTO] { let profile = try await req.profile try await profile.$organizations.load(on: req.db) - return try profile.organizations.map({ try $0.toDTO() }) + return try profile + .organizations + .sorted { ($0.updatedAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } + .map({ try $0.toDTO() }) + } + + func get(req: Request) async throws -> OrganizationDTO { + let organization = try await req.organization(minRole: .lurker) + return try organization.toDTO() } func create(req: Request) async throws -> OrganizationDTO { @@ -109,6 +146,7 @@ struct OrganizationController: RouteCollection { } } + try OrganizationCreateDTO.validate(content: req) let createParams = try req.content.decode(OrganizationCreateDTO.self) let organization = Organization(name: createParams.name) @@ -128,27 +166,28 @@ struct OrganizationController: RouteCollection { try await organizationRole.$profile.load(on: req.db) } - await req.trackAnalyticsEvent(name: "organization_created", params: ["email": profile.email, "organization_id": "\(organizationId)"]) + await req.trackAnalyticsEvent(name: "organization_created", params: ["organization_id": "\(organizationId)"]) return try organization.toDTO() } func patch(req: Request) async throws -> OrganizationDTO { - let organization = try await req.organization(minRole: .admin) - struct OrganizationUpdateDTO: Content, Validatable { - var name: String? - - static func validations(_ validations: inout Validations) { - validations.add("name", as: String?.self, is: .nil || .count(1...100), required: false) - } - } + let organization = try await req.organization(minRole: .admin) + try OrganizationUpdateDTO.validate(content: req) let updateParams = try req.content.decode(OrganizationUpdateDTO.self) organization.name = updateParams.name ?? organization.name + if updateParams.deleteApiKey == true { + organization.apiKey = nil + } else if updateParams.resetApiKey == true { + // FIXME: check if it's unique and retry like 3 times + organization.apiKey = UUID().uuidString + } + try await organization.update(on: req.db) try await organization.$organizationRoles.load(on: req.db) @@ -157,63 +196,77 @@ struct OrganizationController: RouteCollection { try await organizationRole.$profile.load(on: req.db) } - let organizationId = try organization.requireID() - await req.trackAnalyticsEvent(name: "organization_updated", params: ["organization_id": "\(organizationId)"]) + await req.trackAnalyticsEvent(name: "organization_updated", params: ["organization_id": "\(organization.id?.uuidString ?? "???")"]) return try organization.toDTO() } func delete(req: Request) async throws -> HTTPStatus { - let organization = try await req.organization(minRole: .admin) - let organizationName = organization.name - try await organization.delete(on: req.db) - await req.trackAnalyticsEvent(name: "organization_deleted", params: ["organization_name": organizationName]) - - return .noContent - } - - func putOrganizationMembership(req: Request) async throws -> OrganizationMemberDTO { - let profile = try await req.profile - guard let profileId = profile.id else { - throw Abort(.internalServerError) - } - guard let organizationId = req.parameters.get("organizationID").flatMap({ UUID(uuidString: $0) }) else { throw Abort(.badRequest) } - guard let profileToUpdateId = req.parameters.get("profileID").flatMap({ UUID(uuidString: $0) }) else { - throw Abort(.badRequest) + try await profile.$organizationRoles.load(on: req.db) + + guard let profileId = profile.id else { + throw Abort(.internalServerError) } - guard try await ProfileOrganizationRole + guard let role = try await ProfileOrganizationRole .query(on: req.db) .join(Organization.self, on: \ProfileOrganizationRole.$organization.$id == \Organization.$id) .join(Profile.self, on: \ProfileOrganizationRole.$profile.$id == \Profile.$id) .filter(Profile.self, \.$id == profileId) .filter(Organization.self, \.$id == organizationId) - .filter(\.$role == .admin) // only admins can add people - .first() != nil else { + .filter(\.$role == .admin) + .with(\.$organization) + .first() else { throw Abort(.unauthorized) } - struct UpdateRoleDTO: Content { + try await role.organization.delete(on: req.db) + + await req.trackAnalyticsEvent(name: "organization_deleted", params: ["organization_id": "\(role.organization.id?.uuidString ?? "???")"]) + + return .noContent + } + + func putOrganizationMembership(req: Request) async throws -> OrganizationMemberDTO { + + let profile = try await req.profile + let organization = try await req.organization(minRole: .admin) + + guard let organizationId = req.parameters.get("organizationID").flatMap({ UUID(uuidString: $0) }) else { + throw Abort(.internalServerError) + } + + struct UpdateRoleDTO: Content, Validatable { + var email: String var role: OrganizationRoleDTO + + static func validations(_ validations: inout Vapor.Validations) { + validations.add("email", as: String.self, is: .email) + } } + try UpdateRoleDTO.validate(content: req) let update = try req.content.decode(UpdateRoleDTO.self) if let currentRole = try await ProfileOrganizationRole .query(on: req.db) .join(Organization.self, on: \ProfileOrganizationRole.$organization.$id == \Organization.$id) .join(Profile.self, on: \ProfileOrganizationRole.$profile.$id == \Profile.$id) - .filter(Profile.self, \.$id == profileToUpdateId) + .filter(Profile.self, \.$email == update.email) .filter(Organization.self, \.$id == organizationId) + .with(\.$profile) + .with(\.$organization) .first() { + let oldRole = currentRole.role + switch update.role { case .admin: currentRole.role = .admin @@ -223,74 +276,165 @@ struct OrganizationController: RouteCollection { currentRole.role = .lurker } - try await currentRole.update(on: req.db) - return try OrganizationMemberDTO(profile: currentRole.profile.toLiteDTO(), role: update.role) - } else { - - guard let organization = try await Organization.find(organizationId, on: req.db) else { - throw Abort(.notFound) + if oldRole != currentRole.role { + try await currentRole.update(on: req.db) + + await req.trackAnalyticsEvent(name: "organization_member_updated", params: ["organization_id": organizationId.uuidString, "member_email": currentRole.profile.email, "member_role": update.role.rawValue]) } - guard let profileToAdd = try await Profile.find(profileToUpdateId, on: req.db) else { - throw Abort(.notFound) - } + return OrganizationMemberDTO(email: update.email, role: update.role, status: .joined) - try await organization.$profiles.attach(profileToAdd, on: req.db) { pivot in - switch update.role { - case .admin: - pivot.role = .admin - case .editor: - pivot.role = .editor - case .lurker: - pivot.role = .lurker + } else { + + if let profileToAdd = try await Profile.query(on: req.db).filter(\.$email == update.email).first() { + + try await organization.$profiles.attach(profileToAdd, on: req.db) { pivot in + switch update.role { + case .admin: + pivot.role = .admin + case .editor: + pivot.role = .editor + case .lurker: + pivot.role = .lurker + } + } + + await req.trackAnalyticsEvent(name: "organization_member_added", params: ["organization_id": organizationId.uuidString, "member_email": profileToAdd.email, "member_role": update.role.rawValue]) + + let emailBody = """ + Hi \(profileToAdd.name?.split(separator: " ").first ?? "there"), + + This is an automated message to let you know that you've been added to organization \(organization.name) as \(update.role.rawValue) by \(profile.name ?? profile.email). + """ + + do { + try await req.sendEmail(subject: "You've been added to \(organization.name)", message: emailBody, to: update.email) + } catch { + req.logger.error("\(error)") } + + return OrganizationMemberDTO(email: update.email, role: update.role, status: .joined) + + } else { + + if let invitation = try await OrganizationInvite.query(on: req.db).filter(\.$email == update.email).first() { + + let oldRole = invitation.role + switch update.role { + case .admin: + invitation.role = .admin + case .editor: + invitation.role = .editor + case .lurker: + invitation.role = .lurker + } + + if oldRole != invitation.role { + try await invitation.update(on: req.db) + } + + return OrganizationMemberDTO(email: update.email, role: update.role, status: .invited) + + } else { + + let invitation = try OrganizationInvite(email: update.email, role: .admin, organization: organization) + try! await invitation.create(on: req.db) + + await req.trackAnalyticsEvent(name: "organization_member_invitation_created", params: ["organization_id": organizationId.uuidString, "member_email": update.email, "member_role": update.role.rawValue]) + + let emailBody = """ + Hi there, + + This is an automated message to let you know that you've been invited to organization \(organization.name) as \(update.role.rawValue) by \(profile.name ?? profile.email). + """ + + do { + try await req.sendEmail(subject: "You've been ivited to \(organization.name)", message: emailBody, to: update.email) + } catch { + req.logger.error("\(error)") + } + + return OrganizationMemberDTO(email: update.email, role: update.role, status: .invited) + } + } - - return try OrganizationMemberDTO(profile: profileToAdd.toLiteDTO(), role: update.role) } } func deleteOrganizationMembership(req: Request) async throws -> HTTPStatus { let profile = try await req.profile + let organization = try await req.organization(minRole: .admin) + let organizationId = try organization.requireID() - guard let profileId = profile.id else { - throw Abort(.internalServerError) - } - - guard let organizationId = req.parameters.get("organizationID").flatMap({ UUID(uuidString: $0) }) else { - throw Abort(.badRequest) - } - - guard let profileToRemoveId = req.parameters.get("profileID").flatMap({ UUID(uuidString: $0) }) else { + guard let memberEmail = req.parameters.get("memberEmail") else { throw Abort(.badRequest) } - guard try await ProfileOrganizationRole + if let currentRole = try await ProfileOrganizationRole .query(on: req.db) .join(Organization.self, on: \ProfileOrganizationRole.$organization.$id == \Organization.$id) .join(Profile.self, on: \ProfileOrganizationRole.$profile.$id == \Profile.$id) - .filter(Profile.self, \.$id == profileId) + .filter(Profile.self, \.$email == memberEmail) .filter(Organization.self, \.$id == organizationId) - .filter(\.$role == .admin) // only admins can add people - .first() != nil else { + .with(\.$profile) + .with(\.$organization) + .first() { - throw Abort(.unauthorized) + if profile.email == currentRole.profile.email { + // Don't allow to remove myself to avoid people from getting locked out. + // Make this more sophisticated in the future + throw Abort(.forbidden, reason: "Cannot remove yourself") + } + + try await currentRole.delete(on: req.db) + + await req.trackAnalyticsEvent(name: "organization_member_removed", params: ["organization_id": organizationId.uuidString, "member_email": currentRole.profile.email]) + + } else if let invitation = try await OrganizationInvite + .query(on: req.db).filter(\.$email == memberEmail) + .join(Organization.self, on: \OrganizationInvite.$organization.$id == \Organization.$id) + .filter(Organization.self, \.$id == organizationId) + .with(\.$organization) + .first() { + + try await invitation.delete(on: req.db) + + await req.trackAnalyticsEvent(name: "organization_invitation_removed", params: ["organization_id": organizationId.uuidString, "invitation_email": invitation.email]) + + } else { + throw Abort(.notFound) } - guard let profileToDeleteRole = try await ProfileOrganizationRole + return .noContent + } + + func listOrganizationMemberships(req: Request) async throws -> [OrganizationMemberDTO] { + let organization = try await req.organization(minRole: .lurker) + let organizationId = try organization.requireID() + + let currentRoles = try await ProfileOrganizationRole .query(on: req.db) .join(Organization.self, on: \ProfileOrganizationRole.$organization.$id == \Organization.$id) .join(Profile.self, on: \ProfileOrganizationRole.$profile.$id == \Profile.$id) - .filter(Profile.self, \.$id == profileToRemoveId) .filter(Organization.self, \.$id == organizationId) - .first() else { - - throw Abort(.notFound) - } + .with(\.$profile) + .with(\.$organization) + .all() + .map { item in + try item.toDTO() + } - try await profileToDeleteRole.delete(on: req.db) + let currentinvitations = try await OrganizationInvite + .query(on: req.db) + .join(Organization.self, on: \OrganizationInvite.$organization.$id == \Organization.$id) + .filter(Organization.self, \.$id == organizationId) + .with(\.$organization) + .all() + .map { item in + try item.toDTO() + } - return .noContent + return currentinvitations + currentRoles } } diff --git a/Sources/App/Controllers/ProfileController.swift b/Sources/App/Controllers/ProfileController.swift index c35b364..69e0e55 100644 --- a/Sources/App/Controllers/ProfileController.swift +++ b/Sources/App/Controllers/ProfileController.swift @@ -1,12 +1,17 @@ import Fluent import Vapor +import FirebaseJWTMiddleware extension Request { var profile: Profile { get async throws { let token = try await self.jwtUser if let profile = try await Profile.query(on: self.db).filter(\.$firebaseUserId == token.userID).first() { + + try await profile.update(on: db) + return profile + } else { throw Abort(.notFound, reason: "Profile not found.") } @@ -18,20 +23,24 @@ struct ProfileDTO: Content { var id: UUID var email: String var isSubscribedToNewsletter: Bool + var name: String? + var avatarUrl: String? } struct ProfileLiteDTO: Content { var id: UUID var email: String + var name: String? + var avatarUrl: String? } extension Profile { func toDTO() throws -> ProfileDTO { - .init(id: try requireID(), email: email, isSubscribedToNewsletter: subscribedToNewsletterAt != nil) + return .init(id: try requireID(), email: email, isSubscribedToNewsletter: subscribedToNewsletterAt != nil, name: name, avatarUrl: avatarUrl) } func toLiteDTO() throws -> ProfileLiteDTO { - .init(id: try requireID(), email: email) + return .init(id: try requireID(), email: email, name: name, avatarUrl: avatarUrl) } } @@ -49,7 +58,8 @@ struct ProfileController: RouteCollection { } func create(req: Request) async throws -> ProfileDTO { - let token = try await req.jwtUser + let token = try await req.firebaseJwt.asyncVerify() + let avatarUrl = token.picture?.replacingOccurrences(of: "\\/", with: "") if let profile = try await Profile.query(on: req.db).filter(\.$firebaseUserId == token.userID).first() { guard let email = token.email else { @@ -61,15 +71,59 @@ struct ProfileController: RouteCollection { throw Abort(.badRequest, reason: "Firebase user email does not match profile email.") } - await req.trackAnalyticsEvent(name: "profile_created") + if profile.name != token.name { + profile.name = token.name + try await profile.update(on: req.db) + } + + if profile.avatarUrl != avatarUrl { + profile.avatarUrl = avatarUrl + try await profile.update(on: req.db) + } + + try await profile.update(on: req.db) + return try profile.toDTO() } else { guard let email = token.email else { throw Abort(.badRequest, reason: "Firebase user does not have an email address.") } - let profile = Profile(firebaseUserId: token.userID, email: email, name: token.name, avatarUrl: token.picture) + + let profile = Profile(firebaseUserId: token.userID, email: email, name: token.name, avatarUrl: avatarUrl) try await profile.save(on: req.db) + + let invites = try await OrganizationInvite.query(on: req.db).filter(\.$email == profile.email).with(\.$organization).all() + + if invites.isEmpty { + // Create default organization + let organizationName: String + if let usersName = token.name, usersName.isEmpty == false { + organizationName = "\(usersName)'s Organization" + } else { + organizationName = "Default Organization" + } + + let organization = Organization(name: organizationName) + try await organization.create(on: req.db) + + try await organization.$profiles.attach(profile, on: req.db) { pivot in + pivot.role = .admin + } + } else { + + for invite in invites { + + try await invite.organization.$profiles.attach(profile, on: req.db) { pivot in + pivot.role = invite.role + } + + try await invite.delete(on: req.db) + } + } + + await req.trackAnalyticsEvent(name: "profile_created") + return try profile.toDTO() } } @@ -81,6 +135,7 @@ struct ProfileController: RouteCollection { var isSubscribedToNewsletter: Bool? } +// try ProfileUpdateDTO.validate(content: req) let update = try req.content.decode(ProfileUpdateDTO.self) if let isSubscribedToNewsletter = update.isSubscribedToNewsletter { @@ -100,9 +155,8 @@ struct ProfileController: RouteCollection { func delete(req: Request) async throws -> HTTPStatus { // TODO: delete org if it's the last admin member - let profile = try await req.profile - try await profile.delete(on: req.db) - await req.trackAnalyticsEvent(name: "profile_deleted", params: ["email": profile.email]) + try await req.profile.delete(on: req.db) + await req.trackAnalyticsEvent(name: "profile_deleted") return .noContent } } diff --git a/Sources/App/Migrations/CreateOrganizationInvite.swift b/Sources/App/Migrations/CreateOrganizationInvite.swift new file mode 100644 index 0000000..2cbf7f0 --- /dev/null +++ b/Sources/App/Migrations/CreateOrganizationInvite.swift @@ -0,0 +1,27 @@ +// +// File.swift +// +// +// Created by Petr Pavlik on 11.03.2024. +// + +import Fluent + +struct CreateOrganizationInvite: AsyncMigration { + func prepare(on database: Database) async throws { + + try await database.schema(OrganizationInvite.schema) + .id() + .field(.email, .string, .required) + .field(.role, .string, .required) + .field(.createdAt, .datetime) + .field(.updatedAt, .datetime) + .field(.organizationId, .uuid, .references(Organization.schema, "id", onDelete: .cascade)) + .unique(on: .email) + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(OrganizationInvite.schema).delete() + } +} diff --git a/Sources/App/Models/OrganizationInvite.swift b/Sources/App/Models/OrganizationInvite.swift new file mode 100644 index 0000000..324461f --- /dev/null +++ b/Sources/App/Models/OrganizationInvite.swift @@ -0,0 +1,40 @@ +// +// File.swift +// +// +// Created by Petr Pavlik on 11.03.2024. +// + +import Fluent +import Vapor + +final class OrganizationInvite: Model, Content { + static let schema = "organization_invites" + + @ID(key: .id) + var id: UUID? + + @Field(key: .email) + var email: String + + @Field(key: .role) + var role: ProfileOrganizationRole.Role + + @Timestamp(key: .createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: .updatedAt, on: .update) + var updatedAt: Date? + + @Parent(key: .organizationId) + var organization: Organization + + init() { } + + init(id: UUID? = nil, email: String, role: ProfileOrganizationRole.Role, organization: Organization) throws { + self.id = id + self.email = email + self.role = role + self.$organization.id = try organization.requireID() + } +} diff --git a/Sources/App/Utils/Email.swift b/Sources/App/Utils/Email.swift index d41f379..e5d390f 100644 --- a/Sources/App/Utils/Email.swift +++ b/Sources/App/Utils/Email.swift @@ -17,7 +17,7 @@ extension Application { } // Following logic uses an email integrated through STMP to send your transactional emails - // You can replace this with email provider of your choice, like Amazon SES or resend.com + // You can replace this with email provider of your choice, like Amazon SES, resend.com, or indiepitcher.com guard let smtpHostName = Environment.process.SMTP_HOSTNAME else { throw Abort(.internalServerError, reason: "SMTP_HOSTNAME env variable not defined") diff --git a/Sources/App/Utils/SkippableEncoding.swift b/Sources/App/Utils/SkippableEncoding.swift new file mode 100644 index 0000000..ff16d72 --- /dev/null +++ b/Sources/App/Utils/SkippableEncoding.swift @@ -0,0 +1,119 @@ +// +// File.swift +// +// +// Created by Petr Pavlik on 11.03.2024. +// + +import Foundation + +extension SkippableEncoding : Sendable where Wrapped : Sendable {} +extension SkippableEncoding : Hashable where Wrapped : Hashable {} +extension SkippableEncoding : Equatable where Wrapped : Equatable {} + +@propertyWrapper +public enum SkippableEncoding : Codable { + + case skipped + case encoded(Wrapped?) + + public init() { + self = .skipped + } + + public var wrappedValue: Wrapped? { + get { + switch self { + case .skipped: return nil + case .encoded(let v): return v + } + } + set { + self = .encoded(newValue) + } + } + + public var projectedValue: Self { + get {self} + set {self = newValue} + } + + /** Returns `.none` if the value is skipped, `.some(wrappedValue)` if it is not. */ + public var value: Wrapped?? { + switch self { + case .skipped: return nil + case .encoded(let v): return .some(v) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self = try .encoded(container.decode(Wrapped?.self)) + } + + public func encode(to encoder: Encoder) throws { + /* The encoding is taken care of in KeyedEncodingContainer. */ + assertionFailure() + + switch self { + case .skipped: + (/*nop*/) + + case .encoded(let v): + var container = encoder.singleValueContainer() + try container.encode(v) + } + } + +} + +extension KeyedEncodingContainer { + + public mutating func encode(_ value: SkippableEncoding, forKey key: KeyedEncodingContainer.Key) throws { + switch value { + case .skipped: (/*nop*/) + case .encoded(let v): try encode(v, forKey: key) + } + } + +} + +extension UnkeyedEncodingContainer { + + mutating func encode(_ value: SkippableEncoding) throws { + switch value { + case .skipped: (/*nop*/) + case .encoded(let v): try encode(v) + } + } + +} + +extension SingleValueEncodingContainer { + + mutating func encode(_ value: SkippableEncoding) throws { + switch value { + case .skipped: (/*nop*/) + case .encoded(let v): try encode(v) + } + } + +} + +extension KeyedDecodingContainer { + + public func decode(_ type: SkippableEncoding.Type, forKey key: Key) throws -> SkippableEncoding { + /* So IMHO: + * if let decoded = try decodeIfPresent(SkippableEncoding?.self, forKey: key) { + * return decoded ?? SkippableEncoding.encoded(nil) + * } + * should definitely work, but it does not (when the key is present but the value nil, we do not get in the if. + * So instead we try and decode nil directly. + * If that fails (missing key), we fallback to decoding the SkippableEncoding directly if the key is present. */ + if (try? decodeNil(forKey: key)) == true { + return SkippableEncoding.encoded(nil) + } + return try decodeIfPresent(SkippableEncoding.self, forKey: key) ?? SkippableEncoding() + } + +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index e91e43e..0efcbc3 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -51,14 +51,15 @@ public func configure(_ app: Application) async throws { } else { app.logger.warning("Mixpanel disabled, env variables were not provided") } - - } app.migrations.add(CreateProfile()) app.migrations.add(CreateOrganization()) app.migrations.add(CreateProfileOrganizationRole()) + app.migrations.add(CreateOrganizationInvite()) + // You probably want to remove this and run migrations manually if + // you're running more than 1 instance of your backend behind a load balancer if try Environment.detect() != .testing { try await app.autoMigrate() } diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 0e76b4e..4ce895b 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -1,19 +1,42 @@ @testable import App import XCTVapor import Nimble +//import Quick // TODO: make the DTOs conform to Equatable and compare the whole DTOs +extension Application { + static func configuredAppForTests() async throws -> Application { + let app = Application(.testing) + try await configure(app) + + try await app.autoRevert() + try await app.autoMigrate() + + return app + } + + func createProfile(authToken: String) async throws -> ProfileDTO { + var authHeader = HTTPHeaders() + authHeader.bearerAuthorization = .init(token: authToken) + + var profile: ProfileDTO! + + try test(.POST, "profile", headers: authHeader, afterResponse: { res in + expect(res.status) == .ok + profile = try res.content.decode(ProfileDTO.self) + }) + + return profile + } +} + final class AppTests: XCTestCase { private var app: Application! override func setUp() async throws { - app = Application(.testing) - try await configure(app) - - try await app.autoRevert() - try await app.autoMigrate() + app = try await Application.configuredAppForTests() } override func tearDown() async throws { @@ -39,6 +62,10 @@ final class AppTests: XCTestCase { await expect { try await Profile.query(on: self.app.db).count() } == 1 + // default organization is created + await expect { try await Organization.query(on: self.app.db).count() } == 1 + await expect { try await ProfileOrganizationRole.query(on: self.app.db).count() } == 1 + try app.test(.POST, "profile", headers: authHeader, afterResponse: { res in expect(res.status) == .ok let profile = try res.content.decode(ProfileDTO.self) @@ -46,6 +73,10 @@ final class AppTests: XCTestCase { expect(profile.isSubscribedToNewsletter) == false }) + await expect { try await Profile.query(on: self.app.db).count() } == 1 + await expect { try await Organization.query(on: self.app.db).count() } == 1 + await expect { try await ProfileOrganizationRole.query(on: self.app.db).count() } == 1 + try app.test(.GET, "profile", headers: authHeader, afterResponse: { res in expect(res.status) == .ok let profile = try res.content.decode(ProfileDTO.self) @@ -97,6 +128,8 @@ final class AppTests: XCTestCase { expect(profile.isSubscribedToNewsletter) == false }) + await expect { try await Organization.query(on: self.app.db).count() } == 1 + struct OrganizationCreateDTO: Content { var name: String } @@ -110,12 +143,14 @@ final class AppTests: XCTestCase { let organization = try res.content.decode(OrganizationDTO.self) organizationId = organization.id expect(organization.name) == "Test Organization" - expect(organization.members.count) == 1 - expect(organization.members.first?.profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") - expect(organization.members.first?.role) == .admin }) - await expect { try await Organization.query(on: self.app.db).count() } == 1 + await expect { try await Organization.query(on: self.app.db).count() } == 2 + + try app.test(.GET, "organization", headers: authHeader, afterResponse: { res in + expect(res.status) == .ok + let organizations = try res.content.decode([OrganizationDTO].self) + }) try app.test(.PATCH, "organization/\(organizationId.uuidString)", headers: authHeader, beforeRequest: { request in try request.content.encode(OrganizationCreateDTO(name: "New name")) @@ -124,15 +159,14 @@ final class AppTests: XCTestCase { let organization = try res.content.decode(OrganizationDTO.self) organizationId = organization.id expect(organization.name) == "New name" - expect(organization.members.count) == 1 - expect(organization.members.first?.profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") - expect(organization.members.first?.role) == .admin }) // create 2nd user var authHeader2 = HTTPHeaders() let firebaseToken2 = try await app.client.firebaseDefaultUser2Token() authHeader2.bearerAuthorization = .init(token: firebaseToken2) + + await expect { try await Organization.query(on: self.app.db).count() } == 2 var user2Id = "" try app.test(.POST, "profile", headers: authHeader2, afterResponse: { res in @@ -142,27 +176,53 @@ final class AppTests: XCTestCase { user2Id = profile.id.uuidString }) + await expect { try await Organization.query(on: self.app.db).count() } == 3 + struct UpdateRoleDTO: Content { + var email: String var role: OrganizationRoleDTO } - try app.test(.PUT, "organization/\(organizationId.uuidString)/members/\(user2Id)", headers: authHeader, beforeRequest: { request in - try request.content.encode(UpdateRoleDTO(role: .lurker)) + try app.test(.PUT, "organization/\(organizationId.uuidString)/members", headers: authHeader, beforeRequest: { request in + try request.content.encode(UpdateRoleDTO(email: Environment.get("TEST_FIREBASE_USER_2_EMAIL")!, role: .lurker)) }, afterResponse: { res in expect(res.status) == .ok let member = try res.content.decode(OrganizationMemberDTO.self) expect(member.role) == .lurker }) - try app.test(.DELETE, "organization/\(organizationId.uuidString)/members/\(user2Id)", headers: authHeader, afterResponse: { res in + try app.test(.PUT, "organization/\(organizationId.uuidString)/members", headers: authHeader, beforeRequest: { request in + try request.content.encode(UpdateRoleDTO(email: Environment.get("TEST_FIREBASE_USER_2_EMAIL")!, role: .editor)) + }, afterResponse: { res in + expect(res.status) == .ok + let member = try res.content.decode(OrganizationMemberDTO.self) + expect(member.role) == .editor + }) + + try app.test(.DELETE, "organization/\(organizationId.uuidString)/members/\(Environment.get("TEST_FIREBASE_USER_2_EMAIL")!)", headers: authHeader, afterResponse: { res in expect(res.status) == .noContent }) + try app.test(.PUT, "organization/\(organizationId.uuidString)/members", headers: authHeader, beforeRequest: { request in + try request.content.encode(UpdateRoleDTO(email: "unregistered@example.com", role: .admin)) + }, afterResponse: { res in + expect(res.status) == .ok + let member = try res.content.decode(OrganizationMemberDTO.self) + expect(member.email) == "unregistered@example.com" + expect(member.role) == .admin + }) + + try app.test(.DELETE, "organization/\(organizationId.uuidString)/members/unregistered@example.com", headers: authHeader, afterResponse: { res in + expect(res.status) == .noContent + }) + + await expect { try await Organization.query(on: self.app.db).count() } == 3 + try app.test(.DELETE, "organization/\(organizationId.uuidString)", headers: authHeader, afterResponse: { res in expect(res.status) == .noContent }) - await expect { try await Organization.query(on: self.app.db).count() } == 0 + await expect { try await Organization.query(on: self.app.db).count() } == 2 await expect { try await Profile.query(on: self.app.db).count() } == 2 try app.test(.DELETE, "profile", headers: authHeader2, afterResponse: { res in @@ -172,5 +232,7 @@ final class AppTests: XCTestCase { try app.test(.DELETE, "profile", headers: authHeader, afterResponse: { res in expect(res.status) == .noContent }) + + await expect { try await Profile.query(on: self.app.db).count() } == 0 } }