From d0acfd18ffd0ee8eece40500718c8673e52554b2 Mon Sep 17 00:00:00 2001 From: Petr Pavlik Date: Mon, 29 Jan 2024 21:07:04 +0100 Subject: [PATCH] Add name and avatar to user, refactoring (#11) --- Sources/App/Auth/FirebaseAuth.swift | 2 + .../Controllers/OrganizationController.swift | 98 ++++++++----------- .../App/Controllers/ProfileController.swift | 13 ++- Sources/App/Migrations/CreateProfile.swift | 2 + Sources/App/Models/Profile.swift | 10 +- .../App/Models/ProfileOrganizationRole.swift | 10 +- Sources/App/Utils/Analytics.swift | 39 ++++++++ Sources/App/Utils/Email.swift | 59 +++++++++++ Sources/App/configure.swift | 47 --------- 9 files changed, 166 insertions(+), 114 deletions(-) create mode 100644 Sources/App/Utils/Analytics.swift create mode 100644 Sources/App/Utils/Email.swift diff --git a/Sources/App/Auth/FirebaseAuth.swift b/Sources/App/Auth/FirebaseAuth.swift index 1c8022a..281aaaf 100644 --- a/Sources/App/Auth/FirebaseAuth.swift +++ b/Sources/App/Auth/FirebaseAuth.swift @@ -11,6 +11,8 @@ import FirebaseJWTMiddleware protocol JWTUser { var userID: String { get } var email: String? { get } + var name: String? { get } + var picture: String? { get } } extension FirebaseJWTPayload : JWTUser { diff --git a/Sources/App/Controllers/OrganizationController.swift b/Sources/App/Controllers/OrganizationController.swift index 5e905b2..86a5389 100644 --- a/Sources/App/Controllers/OrganizationController.swift +++ b/Sources/App/Controllers/OrganizationController.swift @@ -1,8 +1,39 @@ import Foundation import Fluent import Vapor -import FirebaseJWTMiddleware -import MixpanelVapor + +extension Request { + func organization(minRole: ProfileOrganizationRole.Role) async throws -> Organization { + guard let organizationId = self.parameters.get("organizationID").flatMap({ UUID(uuidString: $0) }) else { + throw Abort(.badRequest) + } + + let profile = try await self.profile + + guard let profileId = profile.id else { + throw Abort(.internalServerError) + } + + guard let membership = try await ProfileOrganizationRole + .query(on: self.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) + .first() else { + + throw Abort(.unauthorized) + } + + guard membership.role >= minRole else { + throw Abort(.unauthorized) + } + + try await membership.$organization.load(on: self.db) + + return membership.organization + } +} enum OrganizationRoleDTO: String, Content { case admin @@ -97,36 +128,13 @@ struct OrganizationController: RouteCollection { try await organizationRole.$profile.load(on: req.db) } - await req.mixpanel.track(name: "organization_created", request: req, params: ["email": profile.email, "organization_id": "\(organizationId)"]) + await req.trackAnalyticsEvent(name: "organization_created", params: ["email": profile.email, "organization_id": "\(organizationId)"]) return try organization.toDTO() } func patch(req: Request) async throws -> OrganizationDTO { - let profile = try await req.profile - - guard let organizationId = req.parameters.get("organizationID").flatMap({ UUID(uuidString: $0) }) else { - throw Abort(.badRequest) - } - - guard let profileId = profile.id else { - throw Abort(.internalServerError) - } - - 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) - .with(\.$organization) - .first() else { - - throw Abort(.unauthorized) - } - - let organization = role.organization + let organization = try await req.organization(minRole: .admin) struct OrganizationUpdateDTO: Content, Validatable { var name: String? @@ -149,41 +157,17 @@ struct OrganizationController: RouteCollection { try await organizationRole.$profile.load(on: req.db) } - await req.mixpanel.track(name: "organization_updated", request: req, params: ["email": profile.email, "organization_id": "\(organizationId)"]) + let organizationId = try organization.requireID() + await req.trackAnalyticsEvent(name: "organization_updated", params: ["organization_id": "\(organizationId)"]) return try organization.toDTO() } func delete(req: Request) async throws -> HTTPStatus { - print("xxx delete organization") - let profile = try await req.profile - - guard let organizationId = req.parameters.get("organizationID").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 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) - .with(\.$organization) - .first() else { - - throw Abort(.unauthorized) - } - - try await role.organization.delete(on: req.db) - - await req.mixpanel.track(name: "organization_deleted", request: req, params: ["email": profile.email, "organization_id": "aaa"]) + 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 } diff --git a/Sources/App/Controllers/ProfileController.swift b/Sources/App/Controllers/ProfileController.swift index 84a0c8e..c35b364 100644 --- a/Sources/App/Controllers/ProfileController.swift +++ b/Sources/App/Controllers/ProfileController.swift @@ -1,6 +1,5 @@ import Fluent import Vapor -import MixpanelVapor extension Request { var profile: Profile { @@ -62,14 +61,14 @@ struct ProfileController: RouteCollection { throw Abort(.badRequest, reason: "Firebase user email does not match profile email.") } - await req.mixpanel.track(name: "profile_created", request: req, params: ["email": email]) + await req.trackAnalyticsEvent(name: "profile_created") 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) + let profile = Profile(firebaseUserId: token.userID, email: email, name: token.name, avatarUrl: token.picture) try await profile.save(on: req.db) return try profile.toDTO() } @@ -87,10 +86,10 @@ struct ProfileController: RouteCollection { if let isSubscribedToNewsletter = update.isSubscribedToNewsletter { if isSubscribedToNewsletter && profile.subscribedToNewsletterAt == nil { profile.subscribedToNewsletterAt = Date() - await req.mixpanel.track(name: "profile_subscribed_to_newsletter", request: req, params: ["email": profile.email]) + await req.trackAnalyticsEvent(name: "profile_subscribed_to_newsletter") } else if profile.subscribedToNewsletterAt != nil { profile.subscribedToNewsletterAt = nil - await req.mixpanel.track(name: "profile_unsubscribed_from_newsletter", request: req, params: ["email": profile.email]) + await req.trackAnalyticsEvent(name: "profile_unsubscribed_from_newsletter") } } @@ -102,8 +101,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 req.profile.delete(on: req.db) - await req.mixpanel.track(name: "profile_deleted", request: req, params: ["email": profile.email]) + try await profile.delete(on: req.db) + await req.trackAnalyticsEvent(name: "profile_deleted", params: ["email": profile.email]) return .noContent } } diff --git a/Sources/App/Migrations/CreateProfile.swift b/Sources/App/Migrations/CreateProfile.swift index ce6a0cf..b9a14df 100644 --- a/Sources/App/Migrations/CreateProfile.swift +++ b/Sources/App/Migrations/CreateProfile.swift @@ -6,6 +6,8 @@ struct CreateProfile: AsyncMigration { .id() .field("firebase_user_id", .string, .required) .field("email", .string, .required) + .field("name", .string) + .field("avatar_url", .string) .field("subscribed_to_newsletter_at", .date) .field("created_at", .datetime) .field("updated_at", .datetime) diff --git a/Sources/App/Models/Profile.swift b/Sources/App/Models/Profile.swift index 355c041..86b899c 100644 --- a/Sources/App/Models/Profile.swift +++ b/Sources/App/Models/Profile.swift @@ -15,6 +15,12 @@ final class Profile: Model, Content { @OptionalField(key: "subscribed_to_newsletter_at") var subscribedToNewsletterAt: Date? + + @OptionalField(key: "name") + var name: String? + + @OptionalField(key: "avatar_url") + var avatarUrl: String? @Timestamp(key: "created_at", on: .create) var createdAt: Date? @@ -30,10 +36,12 @@ final class Profile: Model, Content { init() { } - init(id: UUID? = nil, firebaseUserId: String, email: String, subscribedToNewsletterAt: Date? = nil) { + init(id: UUID? = nil, firebaseUserId: String, email: String, name: String?, avatarUrl: String?, subscribedToNewsletterAt: Date? = nil) { self.id = id self.firebaseUserId = firebaseUserId self.email = email + self.name = name + self.avatarUrl = avatarUrl self.subscribedToNewsletterAt = subscribedToNewsletterAt } } diff --git a/Sources/App/Models/ProfileOrganizationRole.swift b/Sources/App/Models/ProfileOrganizationRole.swift index e04d49f..eee5617 100644 --- a/Sources/App/Models/ProfileOrganizationRole.swift +++ b/Sources/App/Models/ProfileOrganizationRole.swift @@ -3,10 +3,16 @@ import Vapor final class ProfileOrganizationRole: Model { - enum Role: String, Codable { + enum Role: String, Codable, Comparable { + + static func < (lhs: ProfileOrganizationRole.Role, rhs: ProfileOrganizationRole.Role) -> Bool { + let order: [Role] = [.lurker, .editor, .admin] + return order.firstIndex(of: lhs)! < order.firstIndex(of: rhs)! + } + case admin case editor - case lurker + case lurker } static let schema = "organization+profile" diff --git a/Sources/App/Utils/Analytics.swift b/Sources/App/Utils/Analytics.swift new file mode 100644 index 0000000..68b3136 --- /dev/null +++ b/Sources/App/Utils/Analytics.swift @@ -0,0 +1,39 @@ +// +// File.swift +// +// +// Created by Petr Pavlik on 29.01.2024. +// + +import Foundation +import Vapor +import MixpanelVapor + +extension Request { + func trackAnalyticsEvent(name: String, params: [String: any Content] = [:]) async { + + guard application.environment.isRelease else { + return + } + + var params = params + if let profile = try? await profile { + + if let profileId = profile.id { + params["$user_id"] = profileId.uuidString + } + + params["$email"] = profile.email + + if let name = profile.name { + params["$name"] = name + } + if let avatarUrl = profile.avatarUrl { + params["$avatar"] = avatarUrl + } + } + + // Log to a destination of your choice + await mixpanel.track(name: name, request: self, params: params) + } +} diff --git a/Sources/App/Utils/Email.swift b/Sources/App/Utils/Email.swift new file mode 100644 index 0000000..d41f379 --- /dev/null +++ b/Sources/App/Utils/Email.swift @@ -0,0 +1,59 @@ +// +// File.swift +// +// +// Created by Petr Pavlik on 29.01.2024. +// + +import Foundation +import Vapor +import VaporSMTPKit +import SMTPKitten + +extension Application { + func sendEmail(subject: String, message: String, to email: String) async throws { + guard try Environment.detect() != .testing else { + return + } + + // 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 + + guard let smtpHostName = Environment.process.SMTP_HOSTNAME else { + throw Abort(.internalServerError, reason: "SMTP_HOSTNAME env variable not defined") + } + + guard let smtpEmail = Environment.process.SMTP_EMAIL else { + throw Abort(.internalServerError, reason: "SMTP_EMAIL env variable not defined") + } + + guard let smtpPassword = Environment.process.SMTP_PASSWORD else { + throw Abort(.internalServerError, reason: "SMTP_PASSWORD env variable not defined") + } + + let credentials = SMTPCredentials( + hostname: smtpHostName, + ssl: .startTLS(configuration: .default), + email: smtpEmail, + password: smtpPassword + ) + + let email = Mail( + from: .init(name: "[name] from [company]", email: smtpEmail), + to: [ + MailUser(name: nil, email: email) + ], + subject: subject, + contentType: .plain, // supports html + text: message + ) + + try await sendMail(email, withCredentials: credentials).get() + } +} + +extension Request { + func sendEmail(subject: String, message: String, to: String) async throws { + try await self.application.sendEmail(subject: subject, message: message, to: to) + } +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 568a96d..e91e43e 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -2,53 +2,6 @@ import NIOSSL import Fluent import FluentPostgresDriver import Vapor -import VaporSMTPKit -import SMTPKitten - -extension Application { - func sendEmail(subject: String, message: String, to email: String) async throws { - guard try Environment.detect() != .testing else { - return - } - - guard let smtpHostName = Environment.process.SMTP_HOSTNAME else { - throw Abort(.internalServerError, reason: "SMTP_HOSTNAME env variable not defined") - } - - guard let smtpEmail = Environment.process.SMTP_EMAIL else { - throw Abort(.internalServerError, reason: "SMTP_EMAIL env variable not defined") - } - - guard let smtpPassword = Environment.process.SMTP_PASSWORD else { - throw Abort(.internalServerError, reason: "SMTP_PASSWORD env variable not defined") - } - - let credentials = SMTPCredentials( - hostname: smtpHostName, - ssl: .startTLS(configuration: .default), - email: smtpEmail, - password: smtpPassword - ) - - let email = Mail( - from: .init(name: "[name] from [company]", email: smtpEmail), - to: [ - MailUser(name: nil, email: email) - ], - subject: subject, - contentType: .plain, // supports html - text: message - ) - - try await sendMail(email, withCredentials: credentials).get() - } -} - -extension Request { - func sendEmail(subject: String, message: String, to: String) async throws { - try await self.application.sendEmail(subject: subject, message: message, to: to) - } -} // configures your application public func configure(_ app: Application) async throws {