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'