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

support organization invites #13

Merged
merged 2 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
380 changes: 262 additions & 118 deletions Sources/App/Controllers/OrganizationController.swift

Large diffs are not rendered by default.

70 changes: 62 additions & 8 deletions Sources/App/Controllers/ProfileController.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
Expand All @@ -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)
}
}

Expand All @@ -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 {
Expand All @@ -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()
}
}
Expand All @@ -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 {
Expand All @@ -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
}
}
27 changes: 27 additions & 0 deletions Sources/App/Migrations/CreateOrganizationInvite.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
40 changes: 40 additions & 0 deletions Sources/App/Models/OrganizationInvite.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
2 changes: 1 addition & 1 deletion Sources/App/Utils/Email.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
119 changes: 119 additions & 0 deletions Sources/App/Utils/SkippableEncoding.swift
Original file line number Diff line number Diff line change
@@ -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<Wrapped : Codable> : 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<Wrapped>(_ value: SkippableEncoding<Wrapped>, forKey key: KeyedEncodingContainer<K>.Key) throws {
switch value {
case .skipped: (/*nop*/)
case .encoded(let v): try encode(v, forKey: key)
}
}

}

extension UnkeyedEncodingContainer {

mutating func encode<Wrapped>(_ value: SkippableEncoding<Wrapped>) throws {
switch value {
case .skipped: (/*nop*/)
case .encoded(let v): try encode(v)
}
}

}

extension SingleValueEncodingContainer {

mutating func encode<Wrapped>(_ value: SkippableEncoding<Wrapped>) throws {
switch value {
case .skipped: (/*nop*/)
case .encoded(let v): try encode(v)
}
}

}

extension KeyedDecodingContainer {

public func decode<Wrapped>(_ type: SkippableEncoding<Wrapped>.Type, forKey key: Key) throws -> SkippableEncoding<Wrapped> {
/* So IMHO:
* if let decoded = try decodeIfPresent(SkippableEncoding<Wrapped>?.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<Wrapped>.self, forKey: key) ?? SkippableEncoding()
}

}
5 changes: 3 additions & 2 deletions Sources/App/configure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Loading
Loading