From 407bf5d11189a73d162b8726026920b9e2c95cfe Mon Sep 17 00:00:00 2001 From: Petr Pavlik Date: Sat, 4 Nov 2023 21:17:19 +0100 Subject: [PATCH] code --- .dockerignore | 2 + .github/swift.yml | 45 +++ .gitignore | 10 + Dockerfile | 84 +++++ Package.swift | 50 +++ Sources/App/Controllers/.gitkeep | 0 .../Controllers/OrganizationController.swift | 312 ++++++++++++++++++ .../App/Controllers/ProfileController.swift | 118 +++++++ .../App/Migrations/CreateOrganization.swift | 18 + Sources/App/Migrations/CreateProfile.swift | 20 ++ .../CreateProfileOrganizationRole.swift | 27 ++ Sources/App/Models/Organization.swift | 38 +++ Sources/App/Models/Profile.swift | 39 +++ .../App/Models/ProfileOrganizationRole.swift | 40 +++ Sources/App/configure.swift | 108 ++++++ Sources/App/entrypoint.swift | 60 ++++ Sources/App/routes.swift | 11 + Tests/AppTests/AppTests.swift | 176 ++++++++++ Tests/AppTests/Request+FirebaseUser.swift | 58 ++++ docker-compose.yml | 82 +++++ 20 files changed, 1298 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/swift.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Package.swift create mode 100644 Sources/App/Controllers/.gitkeep create mode 100644 Sources/App/Controllers/OrganizationController.swift create mode 100644 Sources/App/Controllers/ProfileController.swift create mode 100644 Sources/App/Migrations/CreateOrganization.swift create mode 100644 Sources/App/Migrations/CreateProfile.swift create mode 100644 Sources/App/Migrations/CreateProfileOrganizationRole.swift create mode 100644 Sources/App/Models/Organization.swift create mode 100644 Sources/App/Models/Profile.swift create mode 100644 Sources/App/Models/ProfileOrganizationRole.swift create mode 100644 Sources/App/configure.swift create mode 100644 Sources/App/entrypoint.swift create mode 100644 Sources/App/routes.swift create mode 100644 Tests/AppTests/AppTests.swift create mode 100644 Tests/AppTests/Request+FirebaseUser.swift create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d9f16e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/.github/swift.yml b/.github/swift.yml new file mode 100644 index 0000000..d863861 --- /dev/null +++ b/.github/swift.yml @@ -0,0 +1,45 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Swift + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:latest + env: + POSTGRES_DB: vapor_database + POSTGRES_PASSWORD: vapor_password + POSTGRES_USER: vapor_username + ports: + - 5433:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v3 + - name: Build + run: swift build -v + - name: Run tests + env: + TEST_FIREBASE_WEB_API_KEY: ${{ secrets.TEST_FIREBASE_WEB_API_KEY }} + TEST_FIREBASE_USER_EMAIL: ${{ secrets.TEST_FIREBASE_USER_EMAIL }} + TEST_FIREBASE_USER_PASSWORD: ${{ secrets.TEST_FIREBASE_USER_PASSWORD }} + TEST_FIREBASE_USER_2_EMAIL: ${{ secrets.TEST_FIREBASE_USER_2_EMAIL }} + TEST_FIREBASE_USER_2_PASSWORD: ${{ secrets.TEST_FIREBASE_USER_2_PASSWORD }} + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + run: swift test -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42aade1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +Packages +.build +xcuserdata +*.xcodeproj +DerivedData/ +.DS_Store +db.sqlite +.swiftpm +.env +/.env.testing diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb7cd16 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,84 @@ +# ================================ +# Build image +# ================================ +FROM swift:5.9-jammy as build + +# Install OS updates +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y\ + && rm -rf /var/lib/apt/lists/* + +# Set up a build area +WORKDIR /build + +# First just resolve dependencies. +# This creates a cached layer that can be reused +# as long as your Package.swift/Package.resolved +# files do not change. +COPY ./Package.* ./ +RUN swift package resolve --skip-update \ + $([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true) + +# Copy entire repo into container +COPY . . + +# Build everything, with optimizations +RUN swift build -c release --static-swift-stdlib \ + # Workaround for https://github.com/apple/swift/pull/68669 + # This can be removed as soon as 5.9.1 is released, but is harmless if left in. + -Xlinker -u -Xlinker _swift_backtrace_isThunkFunction + +# Switch to the staging area +WORKDIR /staging + +# Copy main executable to staging area +RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./ + +# Copy resources bundled by SPM to staging area +RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; + +# Copy any resources from the public directory and views directory if the directories exist +# Ensure that by default, neither the directory nor any of its contents are writable. +RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true +RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true + +# ================================ +# Run image +# ================================ +FROM swift:5.9-jammy-slim + +# Make sure all system packages are up to date, and install only essential packages. +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get -q install -y \ + ca-certificates \ + tzdata \ +# If your app or its dependencies import FoundationNetworking, also install `libcurl4`. + # libcurl4 \ +# If your app or its dependencies import FoundationXML, also install `libxml2`. + # libxml2 \ + && rm -r /var/lib/apt/lists/* + +# Create a vapor user and group with /app as its home directory +RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor + +# Switch to the new home directory +WORKDIR /app + +# Copy built executable and any staged resources from builder +COPY --from=build --chown=vapor:vapor /staging /app + +# Provide configuration needed by the built-in crash reporter and some sensible default behaviors. +ENV SWIFT_ROOT=/usr SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no + +# Ensure all further commands run as the vapor user +USER vapor:vapor + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Start the Vapor service when the image is run, default to listening on 8080 in production environment +ENTRYPOINT ["./App"] +CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..0dcf153 --- /dev/null +++ b/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "SaasTemplate", + platforms: [ + .macOS(.v13) + ], + dependencies: [ + // 💧 A server-side Swift web framework. + .package(url: "https://github.com/vapor/vapor.git", from: "4.83.1"), + // 🗄 An ORM for SQL and NoSQL databases. + .package(url: "https://github.com/vapor/fluent.git", from: "4.8.0"), + // 🐘 Fluent driver for Postgres. + .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.7.2"), + .package(url: "https://github.com/emvakar/vapor-firebase-jwt-middleware.git", branch: "master"), + .package(url: "https://github.com/Quick/Nimble.git", from: "13.0.0"), + .package(url: "https://github.com/petrpavlik/swift-sentry.git", branch: "main"), + .package(url: "https://github.com/petrpavlik/MixpanelVapor.git", from: "0.0.0"), + .package(url: "https://github.com/Joannis/VaporSMTPKit.git", from: "1.0.0"), + ], + targets: [ + .executableTarget( + name: "App", + dependencies: [ + .product(name: "Fluent", package: "fluent"), + .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), + .product(name: "Vapor", package: "vapor"), + .product(name: "FirebaseJWTMiddleware", package: "vapor-firebase-jwt-middleware"), + .product(name: "SwiftSentry", package: "swift-sentry"), + "MixpanelVapor", + .product(name: "VaporSMTPKit", package: "VaporSMTPKit"), + ] + ), + .testTarget(name: "AppTests", dependencies: [ + .target(name: "App"), + .product(name: "XCTVapor", package: "vapor"), + .product(name: "Nimble", package: "Nimble"), + + // Workaround for https://github.com/apple/swift-package-manager/issues/6940 + .product(name: "Vapor", package: "vapor"), + .product(name: "Fluent", package: "Fluent"), + .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), + .product(name: "FirebaseJWTMiddleware", package: "vapor-firebase-jwt-middleware"), + .product(name: "SwiftSentry", package: "swift-sentry"), + "MixpanelVapor", + .product(name: "VaporSMTPKit", package: "VaporSMTPKit"), + ]) + ] +) diff --git a/Sources/App/Controllers/.gitkeep b/Sources/App/Controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Sources/App/Controllers/OrganizationController.swift b/Sources/App/Controllers/OrganizationController.swift new file mode 100644 index 0000000..5e905b2 --- /dev/null +++ b/Sources/App/Controllers/OrganizationController.swift @@ -0,0 +1,312 @@ +import Foundation +import Fluent +import Vapor +import FirebaseJWTMiddleware +import MixpanelVapor + +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.patch(use: patch) + organization.delete(use: delete) + organization.group("members", ":profileID") { members in + members.put(use: putOrganizationMembership) + members.delete(use: deleteOrganizationMembership) + } + } + } + + 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() }) + } + + func create(req: Request) async throws -> OrganizationDTO { + let profile = try await req.profile + + struct OrganizationCreateDTO: Content, Validatable { + var name: String + + static func validations(_ validations: inout Validations) { + validations.add("name", as: String.self, is: .count(1...100)) + } + } + + let createParams = try req.content.decode(OrganizationCreateDTO.self) + + let organization = Organization(name: createParams.name) + try await organization.create(on: req.db) + + try await organization.$profiles.attach(profile, on: req.db) { pivot in + pivot.role = .admin + } + + guard let organizationId = organization.id else { + throw Abort(.internalServerError) + } + + try await organization.$organizationRoles.load(on: req.db) + + for organizationRole in organization.organizationRoles { + try await organizationRole.$profile.load(on: req.db) + } + + await req.mixpanel.track(name: "organization_created", request: req, 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 + + 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 updateParams = try req.content.decode(OrganizationUpdateDTO.self) + + + organization.name = updateParams.name ?? organization.name + + try await organization.update(on: req.db) + + try await organization.$organizationRoles.load(on: req.db) + + for organizationRole in organization.organizationRoles { + try await organizationRole.$profile.load(on: req.db) + } + + await req.mixpanel.track(name: "organization_updated", request: req, params: ["email": profile.email, "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"]) + + 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) + } + + guard 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 { + + throw Abort(.unauthorized) + } + + struct UpdateRoleDTO: Content { + var role: OrganizationRoleDTO + } + + 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(Organization.self, \.$id == organizationId) + .first() { + + switch update.role { + case .admin: + currentRole.role = .admin + case .editor: + currentRole.role = .editor + case .lurker: + 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) + } + + guard let profileToAdd = try await Profile.find(profileToUpdateId, on: req.db) else { + throw Abort(.notFound) + } + + 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 + } + } + + return try OrganizationMemberDTO(profile: profileToAdd.toLiteDTO(), role: update.role) + } + } + + func deleteOrganizationMembership(req: Request) async throws -> HTTPStatus { + + 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 profileToRemoveId = req.parameters.get("profileID").flatMap({ UUID(uuidString: $0) }) else { + throw Abort(.badRequest) + } + + guard 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 { + + throw Abort(.unauthorized) + } + + guard let profileToDeleteRole = 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) + } + + try await profileToDeleteRole.delete(on: req.db) + + return .noContent + } +} diff --git a/Sources/App/Controllers/ProfileController.swift b/Sources/App/Controllers/ProfileController.swift new file mode 100644 index 0000000..96a3d33 --- /dev/null +++ b/Sources/App/Controllers/ProfileController.swift @@ -0,0 +1,118 @@ +import Fluent +import Vapor +import FirebaseJWTMiddleware +import MixpanelVapor + +extension Request { + var profile: Profile { + get async throws { + let token = try await self.firebaseJwt.asyncVerify() + if let profile = try await Profile.query(on: self.db).filter(\.$firebaseUserId == token.userID).first() { + return profile + } else { + throw Abort(.notFound, reason: "Profile not found.") + } + } + } +} + +struct ProfileDTO: Content { + var id: UUID + var email: String + var isSubscribedToNewsletter: Bool +} + +struct ProfileLiteDTO: Content { + var id: UUID + var email: String +} + +extension Profile { + func toDTO() throws -> ProfileDTO { + guard let id else { + throw Abort(.internalServerError, reason: "missing profile id") + } + + return .init(id: id, email: email, isSubscribedToNewsletter: subscribedToNewsletterAt != nil) + } + + func toLiteDTO() throws -> ProfileLiteDTO { + guard let id else { + throw Abort(.internalServerError, reason: "missing profile id") + } + + return .init(id: id, email: email) + } +} + +struct ProfileController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + let profile = routes.grouped("profile") + profile.get(use: index) + profile.post(use: create) + profile.patch(use: update) + profile.delete(use: delete) + } + + func index(req: Request) async throws -> ProfileDTO { + try await req.profile.toDTO() + } + + func create(req: Request) async throws -> ProfileDTO { + let token = try await req.firebaseJwt.asyncVerify() + if let profile = try await Profile.query(on: req.db).filter(\.$firebaseUserId == token.userID).first() { + + guard let email = token.email else { + throw Abort(.badRequest, reason: "Firebase user does not have an email address.") + } + + guard email == profile.email else { + // TODO: We don't currently support changing the email addresses of profiles. + throw Abort(.badRequest, reason: "Firebase user email does not match profile email.") + } + + await req.mixpanel.track(name: "profile_created", request: req, params: ["email": email]) + + 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) + try await profile.save(on: req.db) + return try profile.toDTO() + } + } + + func update(req: Request) async throws -> ProfileDTO { + let profile = try await req.profile + + struct ProfileUpdateDTO: Content { + var isSubscribedToNewsletter: Bool? + } + + let update = try req.content.decode(ProfileUpdateDTO.self) + + 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]) + } else if profile.subscribedToNewsletterAt != nil { + profile.subscribedToNewsletterAt = nil + await req.mixpanel.track(name: "profile_unsubscribed_from_newsletter", request: req, params: ["email": profile.email]) + } + } + + try await profile.update(on: req.db) + + return try profile.toDTO() + } + + 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]) + return .noContent + } +} diff --git a/Sources/App/Migrations/CreateOrganization.swift b/Sources/App/Migrations/CreateOrganization.swift new file mode 100644 index 0000000..767802f --- /dev/null +++ b/Sources/App/Migrations/CreateOrganization.swift @@ -0,0 +1,18 @@ +import Fluent + +struct CreateOrganization: AsyncMigration { + func prepare(on database: Database) async throws { + try await database.schema(Organization.schema) + .id() + .field("name", .string, .required) + .field("api_key", .string) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .unique(on: "api_key") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(Organization.schema).delete() + } +} diff --git a/Sources/App/Migrations/CreateProfile.swift b/Sources/App/Migrations/CreateProfile.swift new file mode 100644 index 0000000..ce6a0cf --- /dev/null +++ b/Sources/App/Migrations/CreateProfile.swift @@ -0,0 +1,20 @@ +import Fluent + +struct CreateProfile: AsyncMigration { + func prepare(on database: Database) async throws { + try await database.schema(Profile.schema) + .id() + .field("firebase_user_id", .string, .required) + .field("email", .string, .required) + .field("subscribed_to_newsletter_at", .date) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .unique(on: "firebase_user_id") + .unique(on: "email") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(Profile.schema).delete() + } +} diff --git a/Sources/App/Migrations/CreateProfileOrganizationRole.swift b/Sources/App/Migrations/CreateProfileOrganizationRole.swift new file mode 100644 index 0000000..764fd15 --- /dev/null +++ b/Sources/App/Migrations/CreateProfileOrganizationRole.swift @@ -0,0 +1,27 @@ +import Fluent + +struct CreateProfileOrganizationRole: AsyncMigration { + func prepare(on database: Database) async throws { + + let organizationRoles = try await database.enum("organization_roles") + .case("admin") + .case("editor") + .case("lurker") + .create() + + try await database.schema(ProfileOrganizationRole.schema) + .id() + .field("role", organizationRoles, .required) + .field("profile_id", .uuid, .references(Profile.schema, "id", onDelete: .cascade)) + .field("organization_id", .uuid, .references(Organization.schema, "id", onDelete: .cascade)) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .unique(on: "profile_id", "organization_id") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(ProfileOrganizationRole.schema).delete() + try await database.enum("organization_roles").delete() + } +} diff --git a/Sources/App/Models/Organization.swift b/Sources/App/Models/Organization.swift new file mode 100644 index 0000000..7d641ca --- /dev/null +++ b/Sources/App/Models/Organization.swift @@ -0,0 +1,38 @@ +import Fluent +import Vapor + +final class Organization: Model, Content { + static let schema = "organizations" + + @ID(key: .id) + var id: UUID? + + @Field(key: "name") + var name: String + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + @OptionalField(key: "api_key") + var apiKey: String? + + @Children(for: \.$organization) + var unsubscribedLeads: [UnsubscribedLeads] + + @Siblings(through: ProfileOrganizationRole.self, from: \.$organization, to: \.$profile) + public var profiles: [Profile] + + @Children(for: \.$organization) + public var organizationRoles: [ProfileOrganizationRole] + + init() { } + + init(id: UUID? = nil, name: String, apiKey: String? = nil) { + self.id = id + self.name = name + self.apiKey = apiKey + } +} diff --git a/Sources/App/Models/Profile.swift b/Sources/App/Models/Profile.swift new file mode 100644 index 0000000..355c041 --- /dev/null +++ b/Sources/App/Models/Profile.swift @@ -0,0 +1,39 @@ +import Fluent +import Vapor + +final class Profile: Model, Content { + static let schema = "profiles" + + @ID(key: .id) + var id: UUID? + + @Field(key: "firebase_user_id") + var firebaseUserId: String + + @Field(key: "email") + var email: String + + @OptionalField(key: "subscribed_to_newsletter_at") + var subscribedToNewsletterAt: Date? + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + @Siblings(through: ProfileOrganizationRole.self, from: \.$profile, to: \.$organization) + public var organizations: [Organization] + + @Children(for: \.$profile) + public var organizationRoles: [ProfileOrganizationRole] + + init() { } + + init(id: UUID? = nil, firebaseUserId: String, email: String, subscribedToNewsletterAt: Date? = nil) { + self.id = id + self.firebaseUserId = firebaseUserId + self.email = email + self.subscribedToNewsletterAt = subscribedToNewsletterAt + } +} diff --git a/Sources/App/Models/ProfileOrganizationRole.swift b/Sources/App/Models/ProfileOrganizationRole.swift new file mode 100644 index 0000000..e04d49f --- /dev/null +++ b/Sources/App/Models/ProfileOrganizationRole.swift @@ -0,0 +1,40 @@ +import Fluent +import Vapor + +final class ProfileOrganizationRole: Model { + + enum Role: String, Codable { + case admin + case editor + case lurker + } + + static let schema = "organization+profile" + + @ID(key: .id) + var id: UUID? + + @Parent(key: "profile_id") + var profile: Profile + + @Parent(key: "organization_id") + var organization: Organization + + @Enum(key: "role") + var role: Role + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + init() { } + + init(id: UUID? = nil, profile: Profile, organization: Organization, role: Role) throws { + self.id = id + self.$profile.id = try profile.requireID() + self.$organization.id = try organization.requireID() + self.role = role + } +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift new file mode 100644 index 0000000..f4f5fe5 --- /dev/null +++ b/Sources/App/configure.swift @@ -0,0 +1,108 @@ +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 { + // uncomment to serve files from /Public folder + // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) + + let corsConfiguration = CORSMiddleware.Configuration( + allowedOrigin: .all, + allowedMethods: [.GET, .POST, .PUT, .OPTIONS, .DELETE, .PATCH], + allowedHeaders: [.accept, .authorization, .contentType, .origin, .xRequestedWith, .userAgent, .accessControlAllowOrigin] + ) + let cors = CORSMiddleware(configuration: corsConfiguration) + // cors middleware should come before default error middleware using `at: .beginning` + app.middleware.use(cors, at: .beginning) + + app.databases.use(DatabaseConfigurationFactory.postgres(configuration: .init( + hostname: Environment.get("DATABASE_HOST") ?? "localhost", + port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? (app.environment == .testing ? 5433 : 5432), + username: Environment.get("DATABASE_USERNAME") ?? "vapor_username", + password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password", + database: Environment.get("DATABASE_NAME") ?? "vapor_database", + tlsConfiguration: app.environment == .testing ? .none : .forClient(certificateVerification: .none)) + ), as: .psql) + + if let firebaseProjectId = Environment.process.FIREBASE_PROJECT_ID { + app.firebaseJwt.applicationIdentifier = firebaseProjectId + } else { + fatalError("FIREBASE_PROJECT_ID not configured") + } + + if app.environment.isRelease { + + if let mixpanelProjectId = Environment.process.MIXPANEL_PROJECT_ID, + let mixpanelUsername = Environment.process.MIXPANEL_USERNAME, + let mixpanelPassword = Environment.process.MIXPANEL_PASSWORD { + + app.mixpanel.configuration = .init(projectId: mixpanelProjectId, + authorization: .init(username: mixpanelUsername, + password: mixpanelPassword)) + } else { + app.logger.warning("Mixpanel disabled, env variables were not provided") + } + + + } + + app.migrations.add(CreateProfile()) + app.migrations.add(CreateOrganization()) + app.migrations.add(CreateProfileOrganizationRole()) + + if try Environment.detect() != .testing { + try await app.autoMigrate() + } + + // register routes + try routes(app) +} diff --git a/Sources/App/entrypoint.swift b/Sources/App/entrypoint.swift new file mode 100644 index 0000000..a834d20 --- /dev/null +++ b/Sources/App/entrypoint.swift @@ -0,0 +1,60 @@ +import Vapor +import Dispatch +import Logging +import SwiftSentry + +/// This extension is temporary and can be removed once Vapor gets this support. +private extension Vapor.Application { + static let baseExecutionQueue = DispatchQueue(label: "vapor.codes.entrypoint") + + func runFromAsyncMainEntrypoint() async throws { + try await withCheckedThrowingContinuation { continuation in + Vapor.Application.baseExecutionQueue.async { [self] in + do { + try self.run() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } +} + +@main +enum Entrypoint { + static func main() async throws { + let env = try Environment.detect() + + var sentry: Sentry? + if env.isRelease { + if let sentryDsn = Environment.process.SENTRY_DSN { + sentry = try Sentry(dsn: sentryDsn) + } + } + + LoggingSystem.bootstrap { label in + var logHandlers = [LogHandler]() + if let sentry { + logHandlers.append(SentryLogHandler(label: label, sentry: sentry, level: .warning)) + } + logHandlers.append(StreamLogHandler.standardOutput(label: label)) + + return MultiplexLogHandler(logHandlers) + } + + let app = Application(env) + defer { + app.shutdown() + try? sentry?.shutdown() + } + + do { + try await configure(app) + } catch { + app.logger.report(error: error) + throw error + } + try await app.runFromAsyncMainEntrypoint() + } +} diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift new file mode 100644 index 0000000..e33b6ad --- /dev/null +++ b/Sources/App/routes.swift @@ -0,0 +1,11 @@ +import Fluent +import Vapor + +func routes(_ app: Application) throws { + app.get { req async in + "It works!" + } + + try app.register(collection: ProfileController()) + try app.register(collection: OrganizationController()) +} diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift new file mode 100644 index 0000000..0e76b4e --- /dev/null +++ b/Tests/AppTests/AppTests.swift @@ -0,0 +1,176 @@ +@testable import App +import XCTVapor +import Nimble + +// TODO: make the DTOs conform to Equatable and compare the whole DTOs + +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() + } + + override func tearDown() async throws { + + app.shutdown() + app = nil + } + + func testProfileController() async throws { + + await expect { try await Profile.query(on: self.app.db).count() } == 0 + + var authHeader = HTTPHeaders() + let firebaseToken = try await app.client.firebaseDefaultUserToken() + authHeader.bearerAuthorization = .init(token: firebaseToken) + + try app.test(.POST, "profile", headers: authHeader, afterResponse: { res in + expect(res.status) == .ok + let profile = try res.content.decode(ProfileDTO.self) + expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") + expect(profile.isSubscribedToNewsletter) == false + }) + + await expect { try await Profile.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) + expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") + expect(profile.isSubscribedToNewsletter) == false + }) + + try app.test(.GET, "profile", headers: authHeader, afterResponse: { res in + expect(res.status) == .ok + let profile = try res.content.decode(ProfileDTO.self) + expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") + expect(profile.isSubscribedToNewsletter) == false + }) + + struct PatchProfileBody: Content { + var isSubscribedToNewsletter: Bool? + } + + try app.test(.PATCH, "profile", headers: authHeader, beforeRequest: { request in + try request.content.encode(PatchProfileBody(isSubscribedToNewsletter: true)) + }, afterResponse: { res in + expect(res.status) == .ok + let profile = try res.content.decode(ProfileDTO.self) + expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") + expect(profile.isSubscribedToNewsletter) == true + }) + + 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 + + try app.test(.POST, "profile", headers: authHeader, afterResponse: { res in + expect(res.status) == .ok + let profile = try res.content.decode(ProfileDTO.self) + expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") + expect(profile.isSubscribedToNewsletter) == false + }) + + await expect { try await Profile.query(on: self.app.db).count() } == 1 + } + + func testOrganizationController() async throws { + + await expect { try await Organization.query(on: self.app.db).count() } == 0 + + var authHeader = HTTPHeaders() + let firebaseToken = try await app.client.firebaseDefaultUserToken() + authHeader.bearerAuthorization = .init(token: firebaseToken) + + try app.test(.POST, "profile", headers: authHeader, afterResponse: { res in + expect(res.status) == .ok + let profile = try res.content.decode(ProfileDTO.self) + expect(profile.email) == Environment.get("TEST_FIREBASE_USER_EMAIL") + expect(profile.isSubscribedToNewsletter) == false + }) + + struct OrganizationCreateDTO: Content { + var name: String + } + + var organizationId: UUID! + + try app.test(.POST, "organization", headers: authHeader, beforeRequest: { request in + try request.content.encode(OrganizationCreateDTO(name: "Test Organization")) + }, afterResponse: { res in + expect(res.status) == .ok + 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 + + try app.test(.PATCH, "organization/\(organizationId.uuidString)", headers: authHeader, beforeRequest: { request in + try request.content.encode(OrganizationCreateDTO(name: "New name")) + }, afterResponse: { res in + expect(res.status) == .ok + 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) + + var user2Id = "" + try app.test(.POST, "profile", headers: authHeader2, afterResponse: { res in + expect(res.status) == .ok + let profile = try res.content.decode(ProfileDTO.self) + expect(profile.email) == Environment.get("TEST_FIREBASE_USER_2_EMAIL") + user2Id = profile.id.uuidString + }) + + struct UpdateRoleDTO: Content { + var role: OrganizationRoleDTO + } + + try app.test(.PUT, "organization/\(organizationId.uuidString)/members/\(user2Id)", headers: authHeader, beforeRequest: { request in + try request.content.encode(UpdateRoleDTO(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 + expect(res.status) == .noContent + }) + + 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 Profile.query(on: self.app.db).count() } == 2 + + try app.test(.DELETE, "profile", headers: authHeader2, afterResponse: { res in + expect(res.status) == .noContent + }) + + try app.test(.DELETE, "profile", headers: authHeader, afterResponse: { res in + expect(res.status) == .noContent + }) + } +} diff --git a/Tests/AppTests/Request+FirebaseUser.swift b/Tests/AppTests/Request+FirebaseUser.swift new file mode 100644 index 0000000..576a4df --- /dev/null +++ b/Tests/AppTests/Request+FirebaseUser.swift @@ -0,0 +1,58 @@ +// +// File.swift +// +// +// Created by Petr Pavlik on 29.10.2023. +// + +import Foundation +import Vapor + +private struct FirebaseSignInRequestDTO: Content { + let email: String + let password: String + let returnSecureToken: Bool +} + +private struct FirebaseSignInResponseDTO: Content { + let idToken: String +} + +extension Client { + func firebaseUserToken(email: String, password: String) async throws -> String { + + guard let webApiKey = Environment.get("TEST_FIREBASE_WEB_API_KEY") else { + throw Abort(.internalServerError, reason: "TEST_FIREBASE_WEB_API_KEY not defined in .env.testing") + } + + let response = try await post("https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=\(webApiKey)", content: FirebaseSignInRequestDTO(email: email, password: password, returnSecureToken: true)) + let decodedResponse = try response.content.decode(FirebaseSignInResponseDTO.self) + return decodedResponse.idToken + } + + func firebaseDefaultUserToken() async throws -> String { + + guard let email = Environment.get("TEST_FIREBASE_USER_EMAIL") else { + throw Abort(.internalServerError, reason: "TEST_FIREBASE_USER_EMAIL not defined in .env.testing") + } + + guard let password = Environment.get("TEST_FIREBASE_USER_PASSWORD") else { + throw Abort(.internalServerError, reason: "TEST_FIREBASE_USER_PASSWORD not defined in .env.testing") + } + + return try await firebaseUserToken(email: email, password: password) + } + + func firebaseDefaultUser2Token() async throws -> String { + + guard let email = Environment.get("TEST_FIREBASE_USER_2_EMAIL") else { + throw Abort(.internalServerError, reason: "TEST_FIREBASE_USER_2_EMAIL not defined in .env.testing") + } + + guard let password = Environment.get("TEST_FIREBASE_USER_2_PASSWORD") else { + throw Abort(.internalServerError, reason: "TEST_FIREBASE_USER_2_PASSWORD not defined in .env.testing") + } + + return try await firebaseUserToken(email: email, password: password) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..962d5ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,82 @@ +# Docker Compose file for Vapor +# +# Install Docker on your system to run and test +# your Vapor app in a production-like environment. +# +# Note: This file is intended for testing and does not +# implement best practices for a production deployment. +# +# Learn more: https://docs.docker.com/compose/reference/ +# +# Build images: docker-compose build +# Start app: docker-compose up app +# Start database: docker-compose up db +# Run migrations: docker-compose run migrate +# Stop all: docker-compose down (add -v to wipe db) +# +version: '3.7' + +volumes: + db_data: + +x-shared_environment: &shared_environment + LOG_LEVEL: ${LOG_LEVEL:-debug} + DATABASE_HOST: db + DATABASE_NAME: vapor_database + DATABASE_USERNAME: vapor_username + DATABASE_PASSWORD: vapor_password + +services: + app: + image: saas-template:latest + build: + context: . + environment: + <<: *shared_environment + depends_on: + - db + ports: + - '8080:8080' + # user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user. + command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] + migrate: + image: saas-template:latest + build: + context: . + environment: + <<: *shared_environment + depends_on: + - db + command: ["migrate", "--yes"] + deploy: + replicas: 0 + revert: + image: saas-template:latest + build: + context: . + environment: + <<: *shared_environment + depends_on: + - db + command: ["migrate", "--revert", "--yes"] + deploy: + replicas: 0 + db: + image: postgres:16-alpine + volumes: + - db_data:/var/lib/postgresql/data/pgdata + environment: + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_USER: vapor_username + POSTGRES_PASSWORD: vapor_password + POSTGRES_DB: vapor_database + ports: + - '5432:5432' + db-test: + image: postgres:16-alpine + environment: + POSTGRES_USER: vapor_username + POSTGRES_PASSWORD: vapor_password + POSTGRES_DB: vapor_database + ports: + - '5433:5432'