Skip to content

Commit

Permalink
support organization invites (#13)
Browse files Browse the repository at this point in the history
* support organization invites

* update
  • Loading branch information
petrpavlik authored Mar 11, 2024
1 parent a2dc9e2 commit 8022ab0
Show file tree
Hide file tree
Showing 8 changed files with 592 additions and 145 deletions.
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

0 comments on commit 8022ab0

Please sign in to comment.