diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml new file mode 100644 index 0000000..68ca0e6 --- /dev/null +++ b/.github/workflows/deploy-backend.yml @@ -0,0 +1,49 @@ +name: Deploy Backend + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to deploy' + required: true + default: 'develop' + type: choice + options: + - develop + +jobs: + deploy: + name: Deploy to AWS + runs-on: ubuntu-latest + + steps: + - name: Deploy to AWS Server + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.AWS_HOST }} + username: ${{ secrets.AWS_USERNAME }} + key: ${{ secrets.AWS_SSH_KEY }} + port: ${{ secrets.AWS_PORT || 22 }} + script: | + # Set the branch to deploy + export DEPLOY_BRANCH="${{ github.event.inputs.branch }}" + + # Navigate to the project directory + cd ${{ secrets.REPO_PATH || '/home/ubuntu/CHConferenceApp' }} + + # Run the deployment script + cd backend + bash deploy.sh + + - name: Deployment Status + if: success() + run: | + echo "✓ Backend deployed successfully from branch: ${{ github.event.inputs.branch }}" + echo "✓ Deployment completed at: $(date)" + + - name: Deployment Failed + if: failure() + run: | + echo "✗ Backend deployment failed" + echo "Please check the AWS server logs for more details" + exit 1 diff --git a/CocoaHeads.xcworkspace/contents.xcworkspacedata b/CocoaHeads.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..de0c3bd --- /dev/null +++ b/CocoaHeads.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CocoaHeads.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CocoaHeads.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..ce14122 --- /dev/null +++ b/CocoaHeads.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,329 @@ +{ + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "7dc119c7edf3c23f52638faadb89182861dee853", + "version" : "1.28.0" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6f3615ccf2ac3c2ae0c8087d527546e9544a43dd", + "version" : "1.21.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", + "version" : "4.15.2" + } + }, + { + "identity" : "fluent", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent.git", + "state" : { + "revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", + "version" : "4.13.0" + } + }, + { + "identity" : "fluent-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-kit.git", + "state" : { + "revision" : "8baacd7e8f7ebf68886c496b43bbe6cdcc5b57e0", + "version" : "1.52.2" + } + }, + { + "identity" : "fluent-postgres-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-postgres-driver.git", + "state" : { + "revision" : "cd47a7042a529735e401bdfaa070823d151f7f94", + "version" : "2.11.0" + } + }, + { + "identity" : "lrucache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nicklockwood/LRUCache.git", + "state" : { + "revision" : "e0e9e039b33db8f2ef39b8e25607e38f46b13584", + "version" : "1.1.2" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "postgres-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-kit.git", + "state" : { + "revision" : "d2fd3172c2e318bd292a4c1297e4c65a418cf6f3", + "version" : "2.14.1" + } + }, + { + "identity" : "postgres-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-nio.git", + "state" : { + "revision" : "8ee6118c03501196be183b0938d2ec4478c18954", + "version" : "1.27.0" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "93f7222c8e195cbad39fafb5a0e4cc85a8def7ea", + "version" : "4.9.2" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "1a9ab0523fb742d9629558cede64290165c4285b", + "version" : "3.33.2" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "4b092f15164144c24554e0a75e080a960c5190a6", + "version" : "1.14.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "6600888f4cb5bbf1bcac51000f60b2cbd224c91b", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "1625f271afb04375bf48737a5572613248d0e7a0", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", + "version" : "2.7.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "a18bddb0acf7a40d982b2f128ce73ce4ee31f352", + "version" : "2.86.2" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "a55c3dd3a81d035af8a20ce5718889c0dcab073d", + "version" : "1.29.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", + "version" : "1.38.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "b2b043a8810ab6d51b3ff4df17f057d87ef1ec7c", + "version" : "2.34.1" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "e645014baea2ec1c2db564410c51a656cf47c923", + "version" : "1.25.1" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", + "version" : "2.8.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version" : "1.6.3" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "4206bc7b8bd9a4ff8e9511211e1b4bff979ef9c4", + "version" : "2.11.1" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "773ea6a63595ae4f6bc46a366d78769d4cb8b08c", + "version" : "4.117.0" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167", + "version" : "2.16.1" + } + } + ], + "version" : 2 +} diff --git a/CocoaHeadsCore/.gitignore b/CocoaHeadsCore/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/CocoaHeadsCore/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/CocoaHeadsCore/Package.swift b/CocoaHeadsCore/Package.swift new file mode 100644 index 0000000..8816cba --- /dev/null +++ b/CocoaHeadsCore/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "CocoaHeadsCore", + platforms: [ + .iOS("18.2"), + .visionOS("2.2"), + .macOS(.v26) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "CocoaHeadsCore", + targets: ["CocoaHeadsCore"] + ) + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "CocoaHeadsCore" + ), + .testTarget( + name: "CocoaHeadsCoreTests", + dependencies: ["CocoaHeadsCore"] + ) + ] +) diff --git a/CocoaHeadsCore/Sources/CocoaHeadsCore/CocoaHeadsCore.swift b/CocoaHeadsCore/Sources/CocoaHeadsCore/CocoaHeadsCore.swift new file mode 100644 index 0000000..08b22b8 --- /dev/null +++ b/CocoaHeadsCore/Sources/CocoaHeadsCore/CocoaHeadsCore.swift @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/CocoaHeadsCore/Sources/CocoaHeadsCore/MeetupEvent/MeetupError.swift b/CocoaHeadsCore/Sources/CocoaHeadsCore/MeetupEvent/MeetupError.swift new file mode 100644 index 0000000..f84bf9b --- /dev/null +++ b/CocoaHeadsCore/Sources/CocoaHeadsCore/MeetupEvent/MeetupError.swift @@ -0,0 +1,20 @@ +// +// MeetupError.swift +// +// +// Created by Mauricio Cardozo on 10/2/25. +// + +public enum MeetupError: Error { + case dateError + case urlError + + public var localizedDescription: String { + switch self { + case .dateError: + "Couldn't decode date" + case .urlError: + "Invalid URL" + } + } +} diff --git a/CocoaHeadsCore/Sources/CocoaHeadsCore/MeetupEvent/MeetupEvent.swift b/CocoaHeadsCore/Sources/CocoaHeadsCore/MeetupEvent/MeetupEvent.swift new file mode 100644 index 0000000..3c56597 --- /dev/null +++ b/CocoaHeadsCore/Sources/CocoaHeadsCore/MeetupEvent/MeetupEvent.swift @@ -0,0 +1,59 @@ +// +// MeetupEvent.swift +// +// +// Created by Mauricio Cardozo on 9/28/25. +// + +import Foundation + +public struct MeetupEvent: Codable, Equatable, Sendable { + public init( + title: String, + address: String, + location: MeetupEvent.Location, + description: String, + date: Date, + url: URL, + image: URL? = nil, + talks: [Talk] = [] + ) { + self.title = title + self.address = address + self.location = location + self.description = description + self.date = date + self.url = url + self.image = image + self.talks = talks + } + + public let title: String + public let address: String + public let location: Location + public let description: String + public let date: Date + public let url: URL + public let image: URL? + public let talks: [Talk] + + public struct Location: Codable, Equatable, Sendable { + public init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + + public let latitude: Double + public let longitude: Double + } + + public struct Talk: Codable, Equatable, Sendable { + public init(speaker: String, title: String) { + self.speaker = speaker + self.title = title + } + + public let speaker: String + public let title: String + } +} diff --git a/CocoaHeadsCore/Sources/CocoaHeadsCore/MeetupEvent/MeetupEventRequest.swift b/CocoaHeadsCore/Sources/CocoaHeadsCore/MeetupEvent/MeetupEventRequest.swift new file mode 100644 index 0000000..d48e29c --- /dev/null +++ b/CocoaHeadsCore/Sources/CocoaHeadsCore/MeetupEvent/MeetupEventRequest.swift @@ -0,0 +1,7 @@ +public struct MeetupEventRequest: Codable { + public init(url: String) { + self.url = url + } + + public let url: String +} diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/Edge+Codable.swift b/CocoaHeadsCore/Sources/CocoaHeadsCore/ServerDrivenUI/EventDetail/Edge+Codable.swift similarity index 100% rename from CocoaHeadsKit/Sources/CocoaHeadsKit/Events/Edge+Codable.swift rename to CocoaHeadsCore/Sources/CocoaHeadsCore/ServerDrivenUI/EventDetail/Edge+Codable.swift diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetailUI.swift b/CocoaHeadsCore/Sources/CocoaHeadsCore/ServerDrivenUI/EventDetail/EventDetailUI.swift similarity index 91% rename from CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetailUI.swift rename to CocoaHeadsCore/Sources/CocoaHeadsCore/ServerDrivenUI/EventDetail/EventDetailUI.swift index 9cac842..87f1885 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetailUI.swift +++ b/CocoaHeadsCore/Sources/CocoaHeadsCore/ServerDrivenUI/EventDetail/EventDetailUI.swift @@ -71,9 +71,10 @@ extension EventDetailUI: Identifiable { } extension EventDetailUI { + // TODO: _spi? // TODO: This should be part of a base set of server driven UIs // eg.: .sdui(.debug(.eventDetail(self))) - func debug() -> EventDetailUI { + public func debug() -> EventDetailUI { .debug(self) } } @@ -84,15 +85,15 @@ extension EventDetailUI { return .card(title: title, edges: .all, insetBy: nil, ui: ui) } - static func callToAction(title: String, url: URL) -> EventDetailUI { + public static func callToAction(title: String, url: URL) -> EventDetailUI { return .callToAction(title: title, url: url, systemImage: nil) } - static func link(_ url: URL) -> EventDetailUI { + public static func link(_ url: URL) -> EventDetailUI { return .link(url, title: nil) } - static func speaker(name: String?, talk: String?) -> EventDetailUI { + public static func speaker(name: String?, talk: String?) -> EventDetailUI { var string = "" if let name { string.append("**\(name)**\n") diff --git a/CocoaHeadsCore/Tests/CocoaHeadsCoreTests/CocoaHeadsCoreTests.swift b/CocoaHeadsCore/Tests/CocoaHeadsCoreTests/CocoaHeadsCoreTests.swift new file mode 100644 index 0000000..90acc4b --- /dev/null +++ b/CocoaHeadsCore/Tests/CocoaHeadsCoreTests/CocoaHeadsCoreTests.swift @@ -0,0 +1,7 @@ +import Testing + +@testable import CocoaHeadsCore + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/CocoaHeadsKit/Package.resolved b/CocoaHeadsKit/Package.resolved new file mode 100644 index 0000000..7a4240c --- /dev/null +++ b/CocoaHeadsKit/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "c5fcf2c62d2164558a4ef0d2dc103cb83ac75e5352f94d739523d470eb0e2369", + "pins" : [ + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "dee225a3da7b68d34936abc4dc8f34f2264db647", + "version" : "2.9.6" + } + } + ], + "version" : 3 +} diff --git a/CocoaHeadsKit/Package.swift b/CocoaHeadsKit/Package.swift index 8b4a7d6..4e8c993 100644 --- a/CocoaHeadsKit/Package.swift +++ b/CocoaHeadsKit/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,8 +6,8 @@ import PackageDescription let package = Package( name: "CocoaHeadsKit", platforms: [ - .iOS("18.2"), - .visionOS("2.2") + .iOS(.v26), + .visionOS(.v26) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. @@ -16,9 +16,9 @@ let package = Package( targets: ["CocoaHeadsKit"]) ], dependencies: [ - .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.8.7"), - .package(path: "Common"), - .package(path: "QAKit") + .package(path: "../Common"), + .package(path: "../QAKit"), + .package(path: "../CocoaHeadsCore") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -26,7 +26,7 @@ let package = Package( .target( name: "CocoaHeadsKit", dependencies: [ - .product(name: "SwiftSoup", package: "SwiftSoup"), + "CocoaHeadsCore", "Common", "QAKit" ] diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/Event+UI.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/Event+UI.swift index 5ab46e4..2494888 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/Event+UI.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/Event+UI.swift @@ -5,6 +5,7 @@ // Created by Mauricio on 5/7/25. // +import CocoaHeadsCore import SwiftUI extension Event { diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetail.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetail.swift index fa0ba4c..5d1b4cc 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetail.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetail.swift @@ -5,6 +5,7 @@ // Created by Mauricio on 4/28/25. // +import CocoaHeadsCore import SwiftUI struct EventDetail: View { @@ -109,33 +110,33 @@ extension View { @ViewBuilder fileprivate func toolbarStyle(scrollPosition: CGFloat) -> some View { #if os(visionOS) - self - .font(.caption) - .foregroundStyle(.white) - .padding() - .background { - Circle() - .fill(Color(.lightGray)) - .shadow(radius: scrollPosition < -100 ? 1 : 0) - .animation(.default, value: scrollPosition) - } - #else - if #available(iOS 26.0, *) { - self - .font(.caption) - .foregroundStyle(Color(.toolbarItem)) - } else { self .font(.caption) - .foregroundStyle(Color.buttonBottomGradient) + .foregroundStyle(.white) .padding() .background { Circle() - .fill(.background) + .fill(Color(.lightGray)) .shadow(radius: scrollPosition < -100 ? 1 : 0) .animation(.default, value: scrollPosition) } - } + #else + if #available(iOS 26.0, *) { + self + .font(.caption) + .foregroundStyle(Color(.toolbarItem)) + } else { + self + .font(.caption) + .foregroundStyle(Color.buttonBottomGradient) + .padding() + .background { + Circle() + .fill(.background) + .shadow(radius: scrollPosition < -100 ? 1 : 0) + .animation(.default, value: scrollPosition) + } + } #endif } } diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetailRenderer.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetailRenderer.swift index 7f6d6e2..98fdcc8 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetailRenderer.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/EventDetailRenderer.swift @@ -5,6 +5,7 @@ // Created by Mauricio on 5/7/25. // +import CocoaHeadsCore import QAKit import SwiftUI diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/TicketCard.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/TicketCard.swift index af93f80..23945fd 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/TicketCard.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Events/TicketCard.swift @@ -27,29 +27,29 @@ struct TicketCard: View { extension View { @ViewBuilder - fileprivate func platformBackground() -> some View{ + fileprivate func platformBackground() -> some View { #if os(visionOS) - self - .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(.lightGray)) - .stroke(.black.opacity(0.1)) - .shadow(color: .black.opacity(0.1), radius: 7, x: 0, y: 3) - .shadow(color: .black.opacity(0.09), radius: 13, x: 0, y: 13) - .shadow(color: .black.opacity(0.05), radius: 18, x: 0, y: 30) - .shadow(color: .black.opacity(0.01), radius: 21, x: 0, y: 54) - } + self + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(.lightGray)) + .stroke(.black.opacity(0.1)) + .shadow(color: .black.opacity(0.1), radius: 7, x: 0, y: 3) + .shadow(color: .black.opacity(0.09), radius: 13, x: 0, y: 13) + .shadow(color: .black.opacity(0.05), radius: 18, x: 0, y: 30) + .shadow(color: .black.opacity(0.01), radius: 21, x: 0, y: 54) + } #else - self - .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(.systemBackground)) - .stroke(.black.opacity(0.1)) - .shadow(color: .black.opacity(0.1), radius: 7, x: 0, y: 3) - .shadow(color: .black.opacity(0.09), radius: 13, x: 0, y: 13) - .shadow(color: .black.opacity(0.05), radius: 18, x: 0, y: 30) - .shadow(color: .black.opacity(0.01), radius: 21, x: 0, y: 54) - } + self + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(.systemBackground)) + .stroke(.black.opacity(0.1)) + .shadow(color: .black.opacity(0.1), radius: 7, x: 0, y: 3) + .shadow(color: .black.opacity(0.09), radius: 13, x: 0, y: 13) + .shadow(color: .black.opacity(0.05), radius: 18, x: 0, y: 30) + .shadow(color: .black.opacity(0.01), radius: 21, x: 0, y: 54) + } #endif } } diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/EventDetailPickerView.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/EventDetailPickerView.swift index 18c35ba..82fe619 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/EventDetailPickerView.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/EventDetailPickerView.swift @@ -5,6 +5,7 @@ // Created by Mauricio on 5/9/25. // +import CocoaHeadsCore import SwiftUI struct EventDetailUIViewer: View { diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/MeetupDebugging.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/MeetupDebugging.swift index 27b81ec..5e38cca 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/MeetupDebugging.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/MeetupDebugging.swift @@ -5,6 +5,7 @@ // Created by Mauricio on 5/9/25. // +import CocoaHeadsCore import SwiftUI struct MeetupDebugging: View { @@ -100,13 +101,13 @@ struct MeetupDebugging: View { eventTitle = event.title eventDate = event.date.formatted() eventLocation = - event.address + "\nlat: \(event.location.coordinate.latitude) lng: \(event.location.coordinate.longitude)" + event.address + "\nlat: \(event.location.latitude) lng: \(event.location.longitude)" eventDescription = event.description eventRSVPLink = event.url.absoluteString meetupEvent = event } catch { eventTitle = - if let error = error as? MeetupService.Error { + if let error = error as? MeetupError { error.localizedDescription } else { "error: \(error)" diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/EventEditingView.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/EventEditingView.swift index 1c30293..a8b5b5e 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/EventEditingView.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/EventEditingView.swift @@ -283,8 +283,8 @@ struct EventCreationView: View { let meetupEvent = try await meetupService.event(from: url.absoluteString) title = meetupEvent.title address = meetupEvent.address - latitude = "\(meetupEvent.location.coordinate.latitude)" - longitude = "\(meetupEvent.location.coordinate.longitude)" + latitude = "\(meetupEvent.location.latitude)" + longitude = "\(meetupEvent.location.longitude)" date = meetupEvent.date errorMessage = nil diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/GenerableMeetupDetails.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/GenerableMeetupDetails.swift new file mode 100644 index 0000000..138323d --- /dev/null +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/GenerableMeetupDetails.swift @@ -0,0 +1,55 @@ +// +// GenerableMeetupDetails.swift +// CocoaHeadsKit +// +// Created by Mauricio Cardozo on 9/30/25. +// + +import FoundationModels +import Playgrounds + +@Generable +struct GeneratedMeetupDetails { + @Guide(description: "Location info") + var locationInfo: String + + @Guide(description: "Details for all speakers") + var speakers: [SpeakerData] + + @Generable + struct SpeakerData { + @Guide(description: "The name of a speaker") + var name: String + + @Guide(description: "The title of that speaker's talk") + var talk: String + } +} + +#Playground { + let text = """ + O CocoaHeads SP é um evento gratuito sobre desenvolvimento e design de tecnologias Apple (iOS, macOS, visionOS, iPadOS, tvOS, watchOS, Swift e tudo que envolve essas plataformas) e vamos nos encontrar mais uma vez para trocar conhecimentos e experiências! + + Para esta edição contaremos com as talks Escalando aplicativos com React Native do Luis Oliveira (OLX) e a talk Um dev iOS se aventurando no macOS, do Bruno Faganello (Thoughtworks) + + • Quer ficar por dentro de tudo que tá rolando no CocoaHeads, quer palestrar ou precisa conversar com a gente? Segue a gente nas redes sociais!👇 + + LinkedIn: CocoaHeads Brasil + Bluesky: [@cocoaheads.com.br](https://bsky.app/profile/cocoaheads.com.br) + Instagram: @CocoaHeadsBr + + • Como chego lá? + O Mackenzie fica na Avenida da Consolação, bem próximo da estação Higienópolis - Mackenzie da linha 4 Amarela, e também existe bicicletário no local. + + Por favor, identifique-se corretamente na pergunta ao se inscrever com o seu RG e CPF. + + Inscrições sem os dados preenchidos vão ser removidas em favor daquelas que foram preenchidas corretamente! + """ + + let session = LanguageModelSession(instructions: "Extract event details from the following text") + + let result = try await session.respond( + to: text, + generating: GeneratedMeetupDetails.self + ) +} diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/MeetupCreator.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/MeetupCreator.swift index 5e3a08d..70d3c08 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/MeetupCreator.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/MeetupCreator.swift @@ -5,6 +5,7 @@ // Created by Mauricio on 5/14/25. // +import CocoaHeadsCore import SwiftUI struct MeetupSpeaker: Identifiable, Equatable { @@ -213,3 +214,8 @@ struct MeetupCreator: View { } } } + +#Preview { + @Previewable @State var ui: [UI] = [] + MeetupCreator(ui: $ui) +} diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/PageCreator.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/PageCreator.swift index 42f0df9..7e9329d 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/PageCreator.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/PageCreator.swift @@ -5,6 +5,7 @@ // Created by Mauricio on 5/9/25. // +import Foundation import SwiftUI struct PageCreator: View { @@ -149,7 +150,7 @@ struct PageCreator: View { extension String { func cleanAndLowercased() -> String { lowercased() - .trim() + .trimmingCharacters(in: .whitespacesAndNewlines) .folding(options: .diacriticInsensitive, locale: .current) .replacingOccurrences(of: " ", with: "") } diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/UIOrderingEditor.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/UIOrderingEditor.swift index 3593547..a3c0f5d 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/UIOrderingEditor.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Hidden/PageCreator/UIOrderingEditor.swift @@ -5,6 +5,7 @@ // Created by Mauricio on 5/14/25. // +import CocoaHeadsCore import SwiftUI struct UIOrderingEditor: View { diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Meetup/Meetup+UI.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Meetup/Meetup+UI.swift index 511a88d..651a2b9 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Meetup/Meetup+UI.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Meetup/Meetup+UI.swift @@ -5,6 +5,7 @@ // Created by Mauricio on 5/9/25. // +import CocoaHeadsCore import Common import CoreLocation import SwiftUI @@ -156,15 +157,15 @@ extension EventDetailUI { ) } - fileprivate static func whereCard(address: String, location: CLLocation) -> EventDetailUI { + fileprivate static func whereCard(address: String, location: MeetupEvent.Location) -> EventDetailUI { .card( title: "Onde", ui: [ .text(address), .map( address: address, - lat: location.coordinate.latitude, - lng: location.coordinate.longitude + lat: location.latitude, + lng: location.longitude ) ] ) diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Meetup/MeetupService.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Meetup/MeetupService.swift index 3ff9479..82600df 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Meetup/MeetupService.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Meetup/MeetupService.swift @@ -5,156 +5,12 @@ // Created by Mauricio on 5/9/25. // +import CocoaHeadsCore import CoreLocation -import SwiftSoup - -struct MeetupEvent: Equatable { - let title: String - let address: String - let location: CLLocation - let description: String - let date: Date - let url: URL - let image: URL? -} struct MeetupService { func event(from urlString: String) async throws -> MeetupEvent { - guard - let url = URL(string: urlString), - case (let data, _) = try await URLSession.shared.data(from: url), - let html = String(data: data, encoding: .utf8) - else { - throw Error.urlError - } - - guard !url.absoluteString.contains("Entrar") else { - throw Error.pastEventError - } - - let document = try SwiftSoup.parse(html) - - return MeetupEvent( - title: try parseTitle(from: document), - address: try parseAddress(from: document), - location: try parseLocation(from: document), - description: try parseDescription(from: document), - date: try parseDate(from: document), - url: url, - image: try parseImage(from: document) - ) - } - - func parseTitle(from document: Document) throws -> String { - let title = try document.title() - return "\(title.split(separator: ",").first ?? "")" - } - - func parseAddress(from document: Document) throws -> String { - guard - let eventLocation = try document.select("[data-event-label='event-location']").first()?.text(), - let addressElement = try document.select("[data-testid='location-info']").first(), - case let address = try addressElement.text() - else { - throw Error.addressError - } - - return eventLocation + "\n" + address - } - - func parseLocation(from document: Document) throws -> CLLocation { - let latlngString = try document.select("[data-event-label='event-map']").first()?.attr("href") ?? "" - let latLng = extractCoordinates(from: latlngString) - guard let (lat, lng) = latLng else { - throw Error.coordinateError - } - - return CLLocation( - latitude: lat, - longitude: lng - ) - } - - private func extractCoordinates(from urlString: String) -> (latitude: Double, longitude: Double)? { - // Example string: https://www.google.com/maps/search/?api=1&query=37.32431%2C%20-122.011566 - guard - let url = URL(string: urlString), - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItem = components.queryItems?.first(where: { $0.name == "query" }), - let coordinatesString = queryItem.value - else { - return nil - } - - let coordinates = coordinatesString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } - - if coordinates.count == 2, - let latitude = Double(coordinates[0]), - let longitude = Double(coordinates[1]) - { - return (latitude, longitude) - } - - return nil - } - - func parseDescription(from document: Document) throws -> String { - try document.select("div#event-details").first()? - .text() - .removingFirstOccurrence(of: "detalhes") - .removingFirstOccurrence(of: "details") ?? "" - } - - func parseDate(from document: Document) throws -> Date { - guard let dateTime = try document.select("time").first()?.attr("datetime") else { - throw Error.dateError - } - return try Date.ISO8601FormatStyle().parse(dateTime) - } - - func parseImage(from document: Document) throws -> URL { - guard - let imageString = try document.select("[data-testid='event-description-image']").select("img").first()?.attr( - "src"), - let url = URL(string: imageString) - else { - throw Error.imageError - } - return url - } - - enum Error: Swift.Error, LocalizedError { - case addressError - case coordinateError - case dateError - case imageError - case pastEventError - case urlError - - var localizedDescription: String { - switch self { - case .addressError: - "Erro ao buscar o endereço" - case .coordinateError: - "Erro no parse das coordenadas" - case .dateError: - "Não consegui encontrar ou fazer decode da data" - case .imageError: - "Não encontrei a URL da imagem" - case .pastEventError: - "Em caso de eventos passados, o Meetup redireciona para uma página de Login, sendo assim, não é possível extrair os dados" - case .urlError: - "Erro na URL" - } - } - } -} - -extension String { - fileprivate func removingFirstOccurrence(of word: String) -> String { - guard let range = range(of: "\\b\(word)\\b", options: [.regularExpression, .caseInsensitive]) else { - return self - } - return replacingCharacters(in: range, with: "") + // TODO: Call API + fatalError("not yet implemented") } } diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Page/PageUI.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Page/PageUI.swift index 109fc3f..c4d9abb 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Page/PageUI.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Page/PageUI.swift @@ -7,6 +7,8 @@ // TODO: We need stable identifiers that don't rely on inner values +import CocoaHeadsCore + indirect enum UI: Codable, Identifiable, Sendable, Equatable { case home(HomeUI) case eventDetail([EventDetailUI]) diff --git a/CocoaHeadsKit/Sources/CocoaHeadsKit/Views/Card.swift b/CocoaHeadsKit/Sources/CocoaHeadsKit/Views/Card.swift index 2b03eba..29a7918 100644 --- a/CocoaHeadsKit/Sources/CocoaHeadsKit/Views/Card.swift +++ b/CocoaHeadsKit/Sources/CocoaHeadsKit/Views/Card.swift @@ -52,24 +52,24 @@ extension View { @ViewBuilder fileprivate func ticketBackground() -> some View { #if os(visionOS) - self - .background { - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(Color(.lightGray)) - .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 4) - } - #else - if #available(iOS 26.0, *) { - self - .glassEffect(in: .rect(cornerRadius: 20, style: .continuous)) - } else { self .background { RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(Color(.systemBackground)) + .fill(Color(.lightGray)) .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 4) } - } + #else + if #available(iOS 26.0, *) { + self + .glassEffect(in: .rect(cornerRadius: 20, style: .continuous)) + } else { + self + .background { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color(.systemBackground)) + .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 4) + } + } #endif } } diff --git a/NSBrazilConf.xcodeproj/project.pbxproj b/NSBrazilConf.xcodeproj/project.pbxproj index eb0dc13..b64f7f2 100644 --- a/NSBrazilConf.xcodeproj/project.pbxproj +++ b/NSBrazilConf.xcodeproj/project.pbxproj @@ -435,7 +435,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "swift format format -i . -r\nswift format lint -r . --strict --parallel\n"; + shellScript = "if [ \"${ENABLE_PREVIEWS}\" = \"YES\" ]; then \n echo 'skipping for previews'\nelse \n swift format format -i . -r\n swift format lint -r . --strict --parallel \nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -609,7 +609,7 @@ INFOPLIST_FILE = NSBrazilConf/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = CocoaHeadsBR; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -639,7 +639,7 @@ INFOPLIST_FILE = NSBrazilConf/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = CocoaHeadsBR; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -670,7 +670,7 @@ ENABLE_PREVIEWS = YES; INFOPLIST_FILE = NSClip/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = CocoaHeadsBR; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -701,7 +701,7 @@ ENABLE_PREVIEWS = YES; INFOPLIST_FILE = NSClip/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = CocoaHeadsBR; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -751,7 +751,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.2; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -788,7 +788,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.2; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; @@ -824,7 +824,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 2.2; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -859,7 +859,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 2.2; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; diff --git a/NSClip/NSClipApp.swift b/NSClip/NSClipApp.swift index ce9adf9..84dd116 100644 --- a/NSClip/NSClipApp.swift +++ b/NSClip/NSClipApp.swift @@ -6,7 +6,7 @@ // Copyright © 2021 Cocoaheadsbr. All rights reserved. // -import CocoaHeadsKit +// import CocoaHeadsKit import StoreKit import SwiftUI @@ -16,13 +16,14 @@ struct NSClipApp: App { @State private var isPresented = false var body: some Scene { WindowGroup { - AppClipView() - .appStoreOverlay(isPresented: $isPresented) { - SKOverlay.AppConfiguration(appIdentifier: "1180455342", position: .bottom) - } - .onAppear { - isPresented = true - } + fatalError("TODO: placeholder until CocoaHeadsKit is broken down into multiple packages") + // AppClipView() + // .appStoreOverlay(isPresented: $isPresented) { + // SKOverlay.AppConfiguration(appIdentifier: "1180455342", position: .bottom) + // } + // .onAppear { + // isPresented = true + // } } } } diff --git a/README.md b/README.md index 6d5b349..50bf086 100644 --- a/README.md +++ b/README.md @@ -28,4 +28,17 @@ * Talks from past conferences * List of all talks separated by day and time -You can use this application to learn about SwiftUI, Combine and contribute to the community. It uses a very standard view, and view model architecture with full use of @State, @Binding, @Published, Observed and Observable object. +You can use this application to learn about SwiftUI, Combine and contribute to the community. It uses a very standard view, and view model architecture with full use of @State, @Binding, @Published, Observed and Observable object. + +## Dependency Graph +```mermaid +flowchart TD + +CocoaHeadsCore <--> Vapor +CocoaHeadsCore <--> CocoaHeadsKit +QAKit --> CocoaHeadsKit +CocoaHeadsKit --> app[NSBrazilConf App] +CocoaHeadsKit --> NSClip +CocoaHeadsKit --> visionOS +CocoaHeadsKit --> watchOS +``` \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..2d9f16e --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..745c700 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,12 @@ +Packages +.build +xcuserdata +*.xcodeproj +DerivedData/ +.DS_Store +db.sqlite +.swiftpm +.env +.env.* +!.env.example +default.profraw diff --git a/backend/.vscode/extensions.json b/backend/.vscode/extensions.json new file mode 100644 index 0000000..42783d7 --- /dev/null +++ b/backend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["swiftlang.swift-vscode", "Vapor.vapor-vscode"] +} \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..008f1bf --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,91 @@ +# ================================ +# Build image +# ================================ +FROM swift:6.2-noble AS build + +# Install OS updates +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q dist-upgrade -y \ + && apt-get install -y libjemalloc-dev + +# 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 \ + $([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true) + +# Copy entire repo into container +COPY . . + +RUN mkdir /staging + +# Build the application, with optimizations, with static linking, and using jemalloc +# N.B.: The static version of jemalloc is incompatible with the static Swift runtime. +RUN --mount=type=cache,target=/build/.build \ + swift build -c release \ + --product backend \ + --static-swift-stdlib \ + -Xlinker -ljemalloc && \ + # Copy main executable to staging area + cp "$(swift build -c release --show-bin-path)/backend" /staging && \ + # Copy resources bundled by SPM to staging area + find -L "$(swift build -c release --show-bin-path)" -regex '.*\.resources$' -exec cp -Ra {} /staging \; + + +# Switch to the staging area +WORKDIR /staging + +# Copy static swift backtracer binary to staging area +RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ + +# 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 ubuntu:noble + +# 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 \ + libjemalloc2 \ + 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_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static + +# 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 ["./backend"] +CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] diff --git a/backend/Package.swift b/backend/Package.swift new file mode 100644 index 0000000..cacf270 --- /dev/null +++ b/backend/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version:6.2 +import PackageDescription + +let package = Package( + name: "backend", + platforms: [ + .macOS(.v26) + ], + dependencies: [ + // 💧 A server-side Swift web framework. + .package(url: "https://github.com/vapor/vapor.git", from: "4.115.0"), + // 🗄 An ORM for SQL and NoSQL databases. + .package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"), + // 🐘 Fluent driver for Postgres. + .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"), + // 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.8.7"), + .package(path: "../CocoaHeadsCore") + ], + targets: [ + .executableTarget( + name: "backend", + dependencies: [ + .product(name: "Fluent", package: "fluent"), + .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), + .product(name: "Vapor", package: "vapor"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "SwiftSoup", package: "SwiftSoup"), + "CocoaHeadsCore" + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "backendTests", + dependencies: [ + .target(name: "backend"), + .product(name: "VaporTesting", package: "vapor") + ], + swiftSettings: swiftSettings + ) + ] +) + +var swiftSettings: [SwiftSetting] { + [ + .enableUpcomingFeature("ExistentialAny") + ] +} diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..5f9c89c --- /dev/null +++ b/backend/README.md @@ -0,0 +1,65 @@ +# backend + +💧 A project built with the Vapor web framework. + +## Getting Started + +To build the project using the Swift Package Manager, run the following command in the terminal from the root of the project: +```bash +swift build +``` + +To run the project and start the server, use the following command: +```bash +swift run +``` + +To execute tests, use the following command: +```bash +swift test +``` + +## Deployment + +**Note**: The production backend is hosted and managed by CocoaHeads Brasil on AWS infrastructure. Deployments are controlled by the CocoaHeads team and are triggered automatically upon merging to the main branch. + +### For CocoaHeads Team Members + +Deployment can be triggered manually via GitHub Actions or directly on the server. + +#### Triggering a Deployment + +1. Navigate to **Actions → Deploy Backend → Run workflow** + +The deployment script will automatically: +1. Pull latest changes from GitHub +2. Stop existing Docker containers +3. Rebuild Docker images +4. Start containers with the new version + +**Note**: Deployments cause brief downtime during rebuild. + +#### Server Configuration + +The production server requires these GitHub Secrets to be configured: +- `AWS_HOST`: AWS server hostname or IP address +- `AWS_USERNAME`: SSH username +- `AWS_SSH_KEY`: Private SSH key for server access +- `AWS_PORT`: SSH port (optional, defaults to 22) +- `REPO_PATH`: Repository path on server (optional, defaults to `/home/ubuntu/CHConferenceApp`) + +### For Contributors + +If you're contributing to the backend, simply create a pull request. Once approved and merged to the main branch, the CocoaHeads team will handle deployment to production. You can test your changes locally using Docker Compose: + +```bash +cd backend +docker compose up --build +``` + +### See more + +- [Vapor Website](https://vapor.codes) +- [Vapor Documentation](https://docs.vapor.codes) +- [Vapor GitHub](https://github.com/vapor) +- [Vapor Community](https://github.com/vapor-community) diff --git a/backend/Sources/backend/Controllers/EventScrape/Firecrawl.swift b/backend/Sources/backend/Controllers/EventScrape/Firecrawl.swift new file mode 100644 index 0000000..6ae2201 --- /dev/null +++ b/backend/Sources/backend/Controllers/EventScrape/Firecrawl.swift @@ -0,0 +1,63 @@ +import Vapor + +// MARK: - Firecrawl Request/Response Models + +struct FirecrawlRequest: Content { + let urls: [String] + let schema: FirecrawlSchema +} + +struct FirecrawlSchema: Codable, Sendable { + let type: String + let properties: [String: SchemaProperty] + let required: [String]? + + init(type: String, properties: [String: SchemaProperty], required: [String]? = nil) { + self.type = type + self.properties = properties + self.required = required + } +} + +struct SchemaProperty: Codable, Sendable { + let type: String + let description: String? + let items: NestedProperty? + + init(type: String, description: String? = nil, items: NestedProperty? = nil) { + self.type = type + self.description = description + self.items = items + } +} + +struct NestedProperty: Codable, Sendable { + let type: String + let properties: [String: SchemaProperty] +} + +struct FirecrawlExtractionStatus: Content { + let status: Status + + enum Status: String, Content { + case completed, processing, failed, cancelled + } +} + +struct FirecrawlResponse: Content { + let success: Bool + let data: T? +} + +struct FirecrawlExtractResponse: Content { + let success: Bool + let id: String +} + +enum FirecrawlError: AbortError { + case missingAPIKey + case extractionFailed + + var status: HTTPResponseStatus { .internalServerError } + var reason: String { localizedDescription } +} diff --git a/backend/Sources/backend/Controllers/EventScrape/FirecrawlService.swift b/backend/Sources/backend/Controllers/EventScrape/FirecrawlService.swift new file mode 100644 index 0000000..c3d9bb9 --- /dev/null +++ b/backend/Sources/backend/Controllers/EventScrape/FirecrawlService.swift @@ -0,0 +1,70 @@ +// +// FirecrawlService.swift +// backend +// +// Created by Mauricio Cardozo on 10/24/25. +// + +import CocoaHeadsCore +import Vapor + +protocol FirecrawlServiceProtocol: Sendable { + func extract( + req: Request, + request: FirecrawlRequest + ) async throws -> T +} + +struct FirecrawlService: FirecrawlServiceProtocol { + static let firecrawlURL = URI(string: "https://api.firecrawl.dev/v2/extract") + + func extract( + req: Request, + request: FirecrawlRequest + ) async throws -> T { + guard let apiKey = Environment.get("FIRECRAWL_API_KEY") else { + throw FirecrawlError.missingAPIKey + } + + let response = try await req.client.post(Self.firecrawlURL) { clientReq in + clientReq.headers.add(name: .authorization, value: "Bearer \(apiKey)") + clientReq.headers.add(name: .contentType, value: "application/json") + try clientReq.content.encode(request) + } + + let firecrawlExtractResponse = try response.content.decode( + FirecrawlExtractResponse.self + ) + + let firecrawlStatusURL = URI( + string: Self.firecrawlURL.string.appending( + "/\(firecrawlExtractResponse.id)" + ) + ) + + var firecrawlStatusResponse = try await req.client.get(firecrawlStatusURL) { clientReq in + clientReq.headers.add(name: .authorization, value: "Bearer \(apiKey)") + clientReq.headers.add(name: .contentType, value: "application/json") + } + + var firecrawlStatus = try firecrawlStatusResponse.content.decode(FirecrawlExtractionStatus.self) + while firecrawlStatus.status == .processing { + try await Task.sleep(for: .seconds(2)) + firecrawlStatusResponse = try await req.client.get(firecrawlStatusURL) { clientReq in + clientReq.headers.add(name: .authorization, value: "Bearer \(apiKey)") + clientReq.headers.add(name: .contentType, value: "application/json") + } + firecrawlStatus = try firecrawlStatusResponse.content.decode(FirecrawlExtractionStatus.self) + } + + let firecrawlResponse = try firecrawlStatusResponse.content.decode( + FirecrawlResponse.self + ) + + guard firecrawlResponse.success, let extractedData = firecrawlResponse.data else { + throw FirecrawlError.extractionFailed + } + + return extractedData + } +} diff --git a/backend/Sources/backend/Controllers/EventScrape/ScrapingController.swift b/backend/Sources/backend/Controllers/EventScrape/ScrapingController.swift new file mode 100644 index 0000000..759bb55 --- /dev/null +++ b/backend/Sources/backend/Controllers/EventScrape/ScrapingController.swift @@ -0,0 +1,147 @@ +// +// ScrapingController.swift +// backend +// +// Created by Mauricio Cardozo on 9/28/25. +// + +import CocoaHeadsCore +import Vapor + +struct ScrapingController: RouteCollection { + let firecrawl: any FirecrawlServiceProtocol + + func boot(routes: any Vapor.RoutesBuilder) throws { + let meetupRoutes = routes.grouped("scrape") + meetupRoutes.post(use: self.event) + } + + @Sendable + func event(req: Request) async throws -> MeetupEvent { + let meetup = try req.content.decode(MeetupEventRequest.self) + + guard let url = URL(string: meetup.url) else { + throw MeetupError.urlError + } + + let extractedData: ExtractedEventData = try await firecrawl.extract( + req: req, + request: createFirecrawlRequest( + for: url + ) + ) + + guard let eventDate = ISO8601DateFormatter().date(from: extractedData.eventDate) else { + throw MeetupError.dateError + } + + let location = MeetupEvent.Location( + latitude: extractedData.latitude ?? 0.0, + longitude: extractedData.longitude ?? 0.0 + ) + + let fullAddress: String + if let addressName = extractedData.addressName { + fullAddress = "\(addressName)\n\(extractedData.address)" + } else { + fullAddress = extractedData.address + } + + let description = extractedData.eventDescription + + let talks = + extractedData.talks?.map { + MeetupEvent.Talk(speaker: $0.speakerName, title: $0.talkTitle) + } ?? [] + + let imageURL = extractedData.coverImageURL.flatMap { URL(string: $0) } + + return MeetupEvent( + title: extractedData.eventTitle, + address: fullAddress, + location: location, + description: description, + date: eventDate, + url: url, + image: imageURL, + talks: talks + ) + } + + private func createFirecrawlRequest(for event: URL) -> FirecrawlRequest { + let schema = FirecrawlSchema( + type: "object", + properties: [ + "event_title": SchemaProperty(type: "string"), + "event_date": SchemaProperty(type: "string", description: "ISO 8601 formatted date"), + "address": SchemaProperty(type: "string"), + "address_name": SchemaProperty(type: "string"), + "latitude": SchemaProperty(type: "number"), + "longitude": SchemaProperty(type: "number"), + "event_description": SchemaProperty(type: "string"), + "cover_image_url": SchemaProperty(type: "string"), + "talks": SchemaProperty( + type: "array", + items: NestedProperty( + type: "object", + properties: [ + "talk_title": SchemaProperty(type: "string"), + "speaker_name": SchemaProperty(type: "string") + ] + ) + ) + ], + required: ["event_title", "event_date", "address", "event_description"] + ) + + return FirecrawlRequest( + urls: [event.absoluteString], + schema: schema + ) + } +} + +struct ExtractedEventData: Content { + let eventTitle: String + let eventDate: String + let address: String + let addressName: String? + let latitude: Double? + let longitude: Double? + let eventDescription: String + let coverImageURL: String? + let talks: [Talk]? + + struct Talk: Codable { + let talkTitle: String + let speakerName: String + + enum CodingKeys: String, CodingKey { + case talkTitle = "talk_title" + case speakerName = "speaker_name" + } + } + + enum CodingKeys: String, CodingKey { + case eventTitle = "event_title" + case eventDate = "event_date" + case address + case addressName = "address_name" + case latitude + case longitude + case eventDescription = "event_description" + case coverImageURL = "cover_image_url" + case talks + } +} + +extension MeetupEvent: @retroactive RequestDecodable {} +extension MeetupEvent: @retroactive ResponseEncodable {} +extension MeetupEvent: @retroactive AsyncRequestDecodable {} +extension MeetupEvent: @retroactive AsyncResponseEncodable {} +extension MeetupEvent: @retroactive Content {} + +extension MeetupError: @retroactive AbortError { + public var status: HTTPStatus { .internalServerError } + public var reason: String { return localizedDescription } +} diff --git a/backend/Sources/backend/configure.swift b/backend/Sources/backend/configure.swift new file mode 100644 index 0000000..ef8312a --- /dev/null +++ b/backend/Sources/backend/configure.swift @@ -0,0 +1,24 @@ +import Fluent +import FluentPostgresDriver +import NIOSSL +import Vapor + +// 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)) + + app.databases.use( + DatabaseConfigurationFactory.postgres( + configuration: .init( + hostname: Environment.get("DATABASE_HOST") ?? "localhost", + port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber, + username: Environment.get("DATABASE_USERNAME") ?? "vapor_username", + password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password", + database: Environment.get("DATABASE_NAME") ?? "vapor_database", + tls: .prefer(try .init(configuration: .clientDefault))) + ), as: .psql) + + // register routes + try routes(app) +} diff --git a/backend/Sources/backend/entrypoint.swift b/backend/Sources/backend/entrypoint.swift new file mode 100644 index 0000000..46f9e9f --- /dev/null +++ b/backend/Sources/backend/entrypoint.swift @@ -0,0 +1,31 @@ +import Logging +import NIOCore +import NIOPosix +import Vapor + +@main +enum Entrypoint { + static func main() async throws { + var env = try Environment.detect() + try LoggingSystem.bootstrap(from: &env) + + let app = try await Application.make(env) + + // This attempts to install NIO as the Swift Concurrency global executor. + // You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency. + // Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down. + // If enabled, you should be careful about calling async functions before this point as it can cause assertion failures. + // let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() + // app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)]) + + do { + try await configure(app) + try await app.execute() + } catch { + app.logger.report(error: error) + try? await app.asyncShutdown() + throw error + } + try await app.asyncShutdown() + } +} diff --git a/backend/Sources/backend/routes.swift b/backend/Sources/backend/routes.swift new file mode 100644 index 0000000..6454d0f --- /dev/null +++ b/backend/Sources/backend/routes.swift @@ -0,0 +1,6 @@ +import Fluent +import Vapor + +func routes(_ app: Application) throws { + try app.register(collection: ScrapingController(firecrawl: FirecrawlService())) +} diff --git a/backend/Tests/backendTests/backendTests.swift b/backend/Tests/backendTests/backendTests.swift new file mode 100644 index 0000000..71d0329 --- /dev/null +++ b/backend/Tests/backendTests/backendTests.swift @@ -0,0 +1,94 @@ +import Fluent +import Testing +import VaporTesting + +@testable import backend + +//@Suite("App Tests with DB", .serialized) +//struct BackendTests { +// private func withApp(_ test: (Application) async throws -> Void) async throws { +// let app = try await Application.make(.testing) +// do { +// try await configure(app) +// try await app.autoMigrate() +// try await test(app) +// try await app.autoRevert() +// } catch { +// try? await app.autoRevert() +// try await app.asyncShutdown() +// throw error +// } +// try await app.asyncShutdown() +// } +// +// @Test("Test Hello World Route") +// func helloWorld() async throws { +// try await withApp { app in +// try await app.testing().test( +// .GET, "hello", +// afterResponse: { res async in +// #expect(res.status == .ok) +// #expect(res.body.string == "Hello, world!") +// }) +// } +// } +// +// @Test("Getting all the Todos") +// func getAllTodos() async throws { +// try await withApp { app in +// let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")] +// try await sampleTodos.create(on: app.db) +// +// try await app.testing().test( +// .GET, "todos", +// afterResponse: { res async throws in +// #expect(res.status == .ok) +// #expect( +// try res.content.decode([TodoDTO].self).sorted(by: { ($0.title ?? "") < ($1.title ?? "") }) +// == sampleTodos.map { $0.toDTO() }.sorted(by: { ($0.title ?? "") < ($1.title ?? "") }) +// ) +// }) +// } +// } +// +// @Test("Creating a Todo") +// func createTodo() async throws { +// let newDTO = TodoDTO(id: nil, title: "test") +// +// try await withApp { app in +// try await app.testing().test( +// .POST, "todos", +// beforeRequest: { req in +// try req.content.encode(newDTO) +// }, +// afterResponse: { res async throws in +// #expect(res.status == .ok) +// let models = try await Todo.query(on: app.db).all() +// #expect(models.map({ $0.toDTO().title }) == [newDTO.title]) +// }) +// } +// } +// +// @Test("Deleting a Todo") +// func deleteTodo() async throws { +// let testTodos = [Todo(title: "test1"), Todo(title: "test2")] +// +// try await withApp { app in +// try await testTodos.create(on: app.db) +// +// try await app.testing().test( +// .DELETE, "todos/\(testTodos[0].requireID())", +// afterResponse: { res async throws in +// #expect(res.status == .noContent) +// let model = try await Todo.find(testTodos[0].id, on: app.db) +// #expect(model == nil) +// }) +// } +// } +//} +// +//extension TodoDTO: Equatable { +// public static func == (lhs: Self, rhs: Self) -> Bool { +// lhs.id == rhs.id && lhs.title == rhs.title +// } +//} diff --git a/backend/deploy.sh b/backend/deploy.sh new file mode 100755 index 0000000..8d3e93c --- /dev/null +++ b/backend/deploy.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# CocoaHeads Backend Deployment Script +# This script pulls the latest code and rebuilds the backend service + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}Starting CocoaHeads Backend Deployment...${NC}" + +# Load deployment configuration if exists +if [ -f .env.deploy ]; then + source .env.deploy +fi + +# Default values +REPO_DIR=${REPO_DIR:-"/home/ubuntu/CHConferenceApp"} +DEPLOY_BRANCH=${DEPLOY_BRANCH:-"develop"} + +echo -e "${YELLOW}Repository: ${REPO_DIR}${NC}" +echo -e "${YELLOW}Branch: ${DEPLOY_BRANCH}${NC}" + +# Navigate to repository root +cd "${REPO_DIR}" + +# Fetch and pull latest changes +echo -e "${YELLOW}Fetching latest changes...${NC}" +git fetch + +echo -e "${YELLOW}Pulling from ${DEPLOY_BRANCH}...${NC}" +git pull origin "${DEPLOY_BRANCH}" + +# Navigate to backend directory +cd backend + +# Check if docker compose is available +if ! command -v docker &> /dev/null; then + echo -e "${RED}Error: Docker is not installed${NC}" + exit 1 +fi + +# Stop existing containers gracefully +echo -e "${YELLOW}Stopping existing containers...${NC}" +sudo docker compose down || true + +# Rebuild and start containers +echo -e "${YELLOW}Building and starting containers...${NC}" +sudo docker compose up -d --build + +# Wait for containers to be healthy +echo -e "${YELLOW}Waiting for containers to start...${NC}" +sleep 5 + +# Check container status +if sudo docker compose ps | grep -q "Up"; then + echo -e "${GREEN}✓ Deployment successful!${NC}" + echo -e "${GREEN}✓ Backend is now running${NC}" + sudo docker compose ps +else + echo -e "${RED}✗ Deployment failed - containers are not running${NC}" + sudo docker compose logs --tail=50 + exit 1 +fi + +echo -e "${GREEN}Deployment completed at $(date)${NC}" diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..a408e19 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,73 @@ +# 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) +# + +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: backend: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: backend:latest + build: + context: . + environment: + <<: *shared_environment + depends_on: + - db + command: ["migrate", "--yes"] + deploy: + replicas: 0 + revert: + image: backend: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'