From f303cb46b76d2d45eb14e0bad812b93a7107053b Mon Sep 17 00:00:00 2001 From: Johan Date: Mon, 26 Jan 2026 00:37:35 +0100 Subject: [PATCH] Initial commit --- Dockerfile | 91 +++++ Package.resolved | 312 ++++++++++++++++++ Package.swift | 44 +++ Public/.gitkeep | 0 README.md | 27 ++ .../Base Infrastructure/Enums/Countries.swift | 268 +++++++++++++++ .../Base Infrastructure/Enums/Database.swift | 26 ++ .../Base Infrastructure/Enums/Role.swift | 17 + .../Base Infrastructure/Enums/Status.swift | 16 + .../Database/ArticleModel.swift | 108 ++++++ .../Infrastructure/Database/CampusModel.swift | 107 ++++++ .../Database/CategoryModel.swift | 57 ++++ .../Database/ConfirmationModel.swift | 78 +++++ .../Infrastructure/Database/CourseModel.swift | 103 ++++++ .../Infrastructure/Database/FileModel.swift | 79 +++++ .../Infrastructure/Database/FolderModel.swift | 59 ++++ .../Infrastructure/Database/LogModel.swift | 72 ++++ .../Database/PurchaseModel.swift | 83 +++++ .../Database/SessionModel.swift | 100 ++++++ .../Infrastructure/Database/TagModel.swift | 31 ++ .../Infrastructure/Database/TokenModel.swift | 45 +++ .../Infrastructure/Database/UserModel.swift | 177 ++++++++++ .../User Repository/UserAuthRepository.swift | 66 ++++ .../User Repository/UserRepository.swift | 46 +++ Sources/ExodaiAcademy/Router/routes.swift | 6 + Sources/ExodaiAcademy/configure.swift | 23 ++ Sources/ExodaiAcademy/entrypoint.swift | 31 ++ .../Extensions/RandomConfirmationCode.swift | 23 ++ .../ExodaiAcademyTests.swift | 84 +++++ docker-compose.yml | 72 ++++ 30 files changed, 2251 insertions(+) create mode 100644 Dockerfile create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Public/.gitkeep create mode 100644 README.md create mode 100644 Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Countries.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Database.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Role.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Status.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/ArticleModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/CampusModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/CategoryModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/ConfirmationModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/CourseModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/FileModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/FolderModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/LogModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/PurchaseModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/SessionModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/TagModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/TokenModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Database/UserModel.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Repositories/User Repository/UserAuthRepository.swift create mode 100644 Sources/ExodaiAcademy/Infrastructure/Repositories/User Repository/UserRepository.swift create mode 100644 Sources/ExodaiAcademy/Router/routes.swift create mode 100644 Sources/ExodaiAcademy/configure.swift create mode 100644 Sources/ExodaiAcademy/entrypoint.swift create mode 100644 Sources/Extensions/RandomConfirmationCode.swift create mode 100644 Tests/ExodaiAcademyTests/ExodaiAcademyTests.swift create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5d8d483 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,91 @@ +# ================================ +# Build image +# ================================ +FROM swift:6.1-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 ExodaiAcademy \ + --static-swift-stdlib \ + -Xlinker -ljemalloc && \ + # Copy main executable to staging area + cp "$(swift build -c release --show-bin-path)/ExodaiAcademy" /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 ["./ExodaiAcademy"] +CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..2aecd7c --- /dev/null +++ b/Package.resolved @@ -0,0 +1,312 @@ +{ + "originHash" : "613dcdfdad088d6787a90d1011bb49c2f514c20f9c4e6f0a6eb7454e985f996d", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "4b99975677236d13f0754339864e5360142ff5a1", + "version" : "1.30.3" + } + }, + { + "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" : "06dadea2c17b3fa4e671f4a3cdc0840e970939b3", + "version" : "1.54.2" + } + }, + { + "identity" : "fluent-postgres-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-postgres-driver.git", + "state" : { + "revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69", + "version" : "2.12.0" + } + }, + { + "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" : "7c079553e9cda74811e627775bf22e40a9405ad9", + "version" : "2.15.1" + } + }, + { + "identity" : "postgres-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-nio.git", + "state" : { + "revision" : "d578b86fb2c8321b114d97cd70831d1a3e9531a6", + "version" : "1.30.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "c0ea243ffeb8b5ff9e20a281e44003c6abb8896f", + "version" : "3.34.0" + } + }, + { + "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" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" + } + }, + { + "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" : "7d5f6124c91a2d06fb63a811695a3400d15a100e", + "version" : "1.17.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", + "version" : "1.9.1" + } + }, + { + "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" : "4a9a97111099376854a7f8f0f9f88b9d61f52eff", + "version" : "2.92.2" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "f4ea23b6c1c545a6656dbcac8c82a2864075766e", + "version" : "1.31.3" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80", + "version" : "1.39.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" + } + }, + { + "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" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "6f3db7122ccffb28e11e121c3797a176fcb88796", + "version" : "4.121.1" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167", + "version" : "2.16.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c0ca283 --- /dev/null +++ b/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "ExodaiAcademy", + platforms: [ + .macOS(.v13) + ], + 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"), + ], + targets: [ + .executableTarget( + name: "ExodaiAcademy", + 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"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "ExodaiAcademyTests", + dependencies: [ + .target(name: "ExodaiAcademy"), + .product(name: "VaporTesting", package: "vapor"), + ], + swiftSettings: swiftSettings + ) + ] +) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ExistentialAny"), +] } diff --git a/Public/.gitkeep b/Public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1acbf1b --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# ExodaiAcademy + +💧 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 +``` + +### 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/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Countries.swift b/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Countries.swift new file mode 100644 index 0000000..2694252 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Countries.swift @@ -0,0 +1,268 @@ +// +// Countries.swift +// ExodaiAcademy +// +// Created by Exodai on 22/01/2026. +// + +import Foundation +import Vapor + +enum CountryCode: String, Codable, CaseIterable { + case af = "AF" // Afghanistan + case ax = "AX" // Åland Islands + case al = "AL" // Albania + case dz = "DZ" // Algeria + case aS = "AS" // American Samoa + case ad = "AD" // Andorra + case ao = "AO" // Angola + case ai = "AI" // Anguilla + case aq = "AQ" // Antarctica + case ag = "AG" // Antigua and Barbuda + case ar = "AR" // Argentina + case am = "AM" // Armenia + case aw = "AW" // Aruba + case au = "AU" // Australia + case at = "AT" // Austria + case az = "AZ" // Azerbaijan + case bs = "BS" // Bahamas + case bh = "BH" // Bahrain + case bd = "BD" // Bangladesh + case bb = "BB" // Barbados + case by = "BY" // Belarus + case be = "BE" // Belgium + case bz = "BZ" // Belize + case bj = "BJ" // Benin + case bm = "BM" // Bermuda + case bt = "BT" // Bhutan + case bo = "BO" // Bolivia + case bq = "BQ" // Bonaire, Sint Eustatius and Saba + case ba = "BA" // Bosnia and Herzegovina + case bw = "BW" // Botswana + case bv = "BV" // Bouvet Island + case br = "BR" // Brazil + case io = "IO" // British Indian Ocean Territory + case bn = "BN" // Brunei + case bg = "BG" // Bulgaria + case bf = "BF" // Burkina Faso + case bi = "BI" // Burundi + case kh = "KH" // Cambodia + case cm = "CM" // Cameroon + case ca = "CA" // Canada + case cv = "CV" // Cape Verde + case ky = "KY" // Cayman Islands + case cf = "CF" // Central African Republic + case td = "TD" // Chad + case cl = "CL" // Chile + case cn = "CN" // China + case cx = "CX" // Christmas Island + case cc = "CC" // Cocos (Keeling) Islands + case co = "CO" // Colombia + case km = "KM" // Comoros + case cg = "CG" // Congo + case cd = "CD" // Congo (DRC) + case ck = "CK" // Cook Islands + case cr = "CR" // Costa Rica + case ci = "CI" // Côte d’Ivoire + case hr = "HR" // Croatia + case cu = "CU" // Cuba + case cw = "CW" // Curaçao + case cy = "CY" // Cyprus + case cz = "CZ" // Czech Republic + case dk = "DK" // Denmark + case dj = "DJ" // Djibouti + case dm = "DM" // Dominica + case dO = "DO" // Dominican Republic + case ec = "EC" // Ecuador + case eg = "EG" // Egypt + case sv = "SV" // El Salvador + case gq = "GQ" // Equatorial Guinea + case er = "ER" // Eritrea + case ee = "EE" // Estonia + case sz = "SZ" // Eswatini + case et = "ET" // Ethiopia + case fk = "FK" // Falkland Islands + case fo = "FO" // Faroe Islands + case fj = "FJ" // Fiji + case fi = "FI" // Finland + case fr = "FR" // France + case gf = "GF" // French Guiana + case pf = "PF" // French Polynesia + case tf = "TF" // French Southern Territories + case ga = "GA" // Gabon + case gm = "GM" // Gambia + case ge = "GE" // Georgia + case de = "DE" // Germany + case gh = "GH" // Ghana + case gi = "GI" // Gibraltar + case gr = "GR" // Greece + case gl = "GL" // Greenland + case gd = "GD" // Grenada + case gp = "GP" // Guadeloupe + case gu = "GU" // Guam + case gt = "GT" // Guatemala + case gg = "GG" // Guernsey + case gn = "GN" // Guinea + case gw = "GW" // Guinea-Bissau + case gy = "GY" // Guyana + case ht = "HT" // Haiti + case hm = "HM" // Heard Island and McDonald Islands + case va = "VA" // Vatican City + case hn = "HN" // Honduras + case hk = "HK" // Hong Kong + case hu = "HU" // Hungary + case iS = "IS" // Iceland + case iN = "IN" // India + case id = "ID" // Indonesia + case ir = "IR" // Iran + case iq = "IQ" // Iraq + case ie = "IE" // Ireland + case im = "IM" // Isle of Man + case il = "IL" // Israel + case it = "IT" // Italy + case jm = "JM" // Jamaica + case jp = "JP" // Japan + case je = "JE" // Jersey + case jo = "JO" // Jordan + case kz = "KZ" // Kazakhstan + case ke = "KE" // Kenya + case ki = "KI" // Kiribati + case kp = "KP" // North Korea + case kr = "KR" // South Korea + case kw = "KW" // Kuwait + case kg = "KG" // Kyrgyzstan + case la = "LA" // Laos + case lv = "LV" // Latvia + case lb = "LB" // Lebanon + case ls = "LS" // Lesotho + case lr = "LR" // Liberia + case ly = "LY" // Libya + case li = "LI" // Liechtenstein + case lt = "LT" // Lithuania + case lu = "LU" // Luxembourg + case mo = "MO" // Macao + case mg = "MG" // Madagascar + case mw = "MW" // Malawi + case my = "MY" // Malaysia + case mv = "MV" // Maldives + case ml = "ML" // Mali + case mt = "MT" // Malta + case mh = "MH" // Marshall Islands + case mq = "MQ" // Martinique + case mr = "MR" // Mauritania + case mu = "MU" // Mauritius + case yt = "YT" // Mayotte + case mx = "MX" // Mexico + case fm = "FM" // Micronesia + case md = "MD" // Moldova + case mc = "MC" // Monaco + case mn = "MN" // Mongolia + case me = "ME" // Montenegro + case ms = "MS" // Montserrat + case ma = "MA" // Morocco + case mz = "MZ" // Mozambique + case mm = "MM" // Myanmar + case na = "NA" // Namibia + case nr = "NR" // Nauru + case np = "NP" // Nepal + case nl = "NL" // Netherlands + case nc = "NC" // New Caledonia + case nz = "NZ" // New Zealand + case ni = "NI" // Nicaragua + case ne = "NE" // Niger + case ng = "NG" // Nigeria + case nu = "NU" // Niue + case nf = "NF" // Norfolk Island + case mk = "MK" // North Macedonia + case mp = "MP" // Northern Mariana Islands + case no = "NO" // Norway + case om = "OM" // Oman + case pk = "PK" // Pakistan + case pw = "PW" // Palau + case ps = "PS" // Palestine + case pa = "PA" // Panama + case pg = "PG" // Papua New Guinea + case py = "PY" // Paraguay + case pe = "PE" // Peru + case ph = "PH" // Philippines + case pn = "PN" // Pitcairn + case pl = "PL" // Poland + case pt = "PT" // Portugal + case pr = "PR" // Puerto Rico + case qa = "QA" // Qatar + case re = "RE" // Réunion + case ro = "RO" // Romania + case ru = "RU" // Russia + case rw = "RW" // Rwanda + case bl = "BL" // Saint Barthélemy + case sh = "SH" // Saint Helena + case kn = "KN" // Saint Kitts and Nevis + case lc = "LC" // Saint Lucia + case mf = "MF" // Saint Martin + case pm = "PM" // Saint Pierre and Miquelon + case vc = "VC" // Saint Vincent and the Grenadines + case ws = "WS" // Samoa + case sm = "SM" // San Marino + case st = "ST" // São Tomé and Príncipe + case sa = "SA" // Saudi Arabia + case sn = "SN" // Senegal + case rs = "RS" // Serbia + case sc = "SC" // Seychelles + case sl = "SL" // Sierra Leone + case sg = "SG" // Singapore + case sx = "SX" // Sint Maarten + case sk = "SK" // Slovakia + case si = "SI" // Slovenia + case sb = "SB" // Solomon Islands + case so = "SO" // Somalia + case za = "ZA" // South Africa + case gs = "GS" // South Georgia + case ss = "SS" // South Sudan + case es = "ES" // Spain + case lk = "LK" // Sri Lanka + case sd = "SD" // Sudan + case sr = "SR" // Suriname + case sj = "SJ" // Svalbard and Jan Mayen + case se = "SE" // Sweden + case ch = "CH" // Switzerland + case sy = "SY" // Syria + case tw = "TW" // Taiwan + case tj = "TJ" // Tajikistan + case tz = "TZ" // Tanzania + case th = "TH" // Thailand + case tl = "TL" // Timor-Leste + case tg = "TG" // Togo + case tk = "TK" // Tokelau + case to = "TO" // Tonga + case tt = "TT" // Trinidad and Tobago + case tn = "TN" // Tunisia + case tr = "TR" // Turkey + case tm = "TM" // Turkmenistan + case tc = "TC" // Turks and Caicos Islands + case tv = "TV" // Tuvalu + case ug = "UG" // Uganda + case ua = "UA" // Ukraine + case ae = "AE" // United Arab Emirates + case gb = "GB" // United Kingdom + case us = "US" // United States + case um = "UM" // U.S. Minor Outlying Islands + case uy = "UY" // Uruguay + case uz = "UZ" // Uzbekistan + case vu = "VU" // Vanuatu + case ve = "VE" // Venezuela + case vn = "VN" // Vietnam + case vg = "VG" // British Virgin Islands + case vi = "VI" // U.S. Virgin Islands + case wf = "WF" // Wallis and Futuna + case eh = "EH" // Western Sahara + case ye = "YE" // Yemen + case zm = "ZM" // Zambia + case zw = "ZW" // Zimbabwe +} + +extension CountryCode { + /// Returns the localized full country name (e.g. "Germany", "Deutschland") + func fullName(locale: Locale = .current) -> String { + locale.localizedString(forRegionCode: self.rawValue) ?? self.rawValue + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Database.swift b/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Database.swift new file mode 100644 index 0000000..2bc90ce --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Database.swift @@ -0,0 +1,26 @@ +// +// File.swift +// ExodaiAcademy +// +// Created by Exodai on 22/01/2026. +// + +import Foundation + +enum Database: String, Codable { + case users + case campuses + case courses + case sessions + case articles + case categories + case tags + case logs + case purchases + case products + case confirmations + case tokens + case folders + case files + +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Role.swift b/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Role.swift new file mode 100644 index 0000000..fe4bd4c --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Role.swift @@ -0,0 +1,17 @@ +// +// Role.swift +// ExodaiAcademy +// +// Created by Exodai on 22/01/2026. +// + +enum Role: String, Codable { + case admin + case editor + case leadEditor + case writer + case reviewer + case moderator + case user + case blocked +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Status.swift b/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Status.swift new file mode 100644 index 0000000..306b1af --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Base Infrastructure/Enums/Status.swift @@ -0,0 +1,16 @@ +// +// Status.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +enum Status: String, Codable { + case published + case draft + case planned + case inReview + case archived + case trash + +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/ArticleModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/ArticleModel.swift new file mode 100644 index 0000000..2c1613b --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/ArticleModel.swift @@ -0,0 +1,108 @@ +// +// ArticleModel.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +import Fluent + +final class ArticleModel: Model, @unchecked Sendable { + static let schema: String = Database.articles.rawValue + + // MARK: - ID + + @ID(key: .id) + var id: UUID? + + // MARK: - CMS Fields (all optional) + + @OptionalField(key: FieldKeys.title) + var title: String? + + @OptionalField(key: FieldKeys.description) + var description: String? + + @OptionalField(key: FieldKeys.slug) + var slug: String? + + @OptionalField(key: FieldKeys.excerpt) + var excerpt: String? + + @OptionalField(key: FieldKeys.image) + var image: String? + + @OptionalEnum(key: FieldKeys.status) + var status: Status? + + @OptionalField(key: FieldKeys.authorID) + var authorID: UserModel.IDValue? + + @OptionalField(key: FieldKeys.categories) + var categories: [CategoryModel.IDValue]? + + @OptionalField(key: FieldKeys.tags) + var tags: [TagModel.IDValue]? + + // MARK: - Timestamps + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: FieldKeys.updatedAt, on: .update) + var updatedAt: Date? + + @OptionalField(key: FieldKeys.publishDate) + var publishDate: Date? + + // MARK: - Initializer + + init() {} +} + + +extension ArticleModel { + struct FieldKeys { + static var title: FieldKey { "title" } + static var description: FieldKey { "description" } + static var slug: FieldKey { "slug" } + static var excerpt: FieldKey { "excerpt" } + static var image: FieldKey { "image" } + static var status: FieldKey { "status" } + static var authorID: FieldKey { "authorID" } + static var categories: FieldKey { "categories" } + static var tags: FieldKey { "tags" } + static var createdAt: FieldKey { "createdAt" } + static var updatedAt: FieldKey { "updatedAt" } + static var publishDate: FieldKey { "publishDate" } + } +} + +import Fluent + +extension ArticleModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(ArticleModel.schema) + .id() + .field(FieldKeys.title, .string) + .field(FieldKeys.description, .string) + .field(FieldKeys.slug, .string) + .field(FieldKeys.excerpt, .string) + .field(FieldKeys.image, .string) + .field(FieldKeys.status, .string) + .field(FieldKeys.authorID, .uuid) + .field(FieldKeys.categories, .array(of: .uuid)) + .field(FieldKeys.tags, .array(of: .uuid)) + .field(FieldKeys.createdAt, .datetime) + .field(FieldKeys.updatedAt, .datetime) + .field(FieldKeys.publishDate, .datetime) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(ArticleModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/CampusModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/CampusModel.swift new file mode 100644 index 0000000..d335d0d --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/CampusModel.swift @@ -0,0 +1,107 @@ +// +// CampusModel.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +import Fluent + +final class CampusModel: Model, @unchecked Sendable { + static let schema: String = Database.campuses.rawValue + + // MARK: - ID + + @ID(key: .id) + var id: UUID? + + // MARK: - Data + + @OptionalField(key: FieldKeys.title) + var title: String? + + @OptionalField(key: FieldKeys.description) + var description: String? + + @OptionalField(key: FieldKeys.slug) + var slug: String? + + @OptionalField(key: FieldKeys.content) + var content: String? + + @Enum(key: FieldKeys.status) + var status: Status + + @Field(key: FieldKeys.instructorID) + var instructorID: UserModel.IDValue + + @OptionalField(key: FieldKeys.price) + var price: Double? + + @OptionalField(key: FieldKeys.tags) + var tags: [TagModel.IDValue]? + + @OptionalField(key: FieldKeys.categories) + var categories: [CategoryModel.IDValue]? + + // MARK: - Timestamps + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: FieldKeys.updatedAt, on: .update) + var updatedAt: Date? + + @Field(key: FieldKeys.publishDate) + var publishDate: Date? + + // MARK: - Initializers + + init() {} +} + +extension CampusModel { + struct FieldKeys { + static var title: FieldKey { "title" } + static var description: FieldKey { "description" } + static var slug: FieldKey {"slug"} + static var content: FieldKey { "content" } + static var status: FieldKey { "status" } + static var instructorID: FieldKey { "instructorID" } + static var price: FieldKey { "price" } + static var tags: FieldKey { "tags" } + static var categories: FieldKey { "categories" } + static var createdAt: FieldKey { "createdAt" } + static var updatedAt: FieldKey { "updatedAt" } + static var publishDate: FieldKey { "publishDate" } + } +} + +import Fluent + +extension CampusModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(CampusModel.schema) + .id() + .field(FieldKeys.title, .string) + .field(FieldKeys.description, .string) + .field(FieldKeys.slug, .string) + .field(FieldKeys.content, .string) + .field(FieldKeys.status, .string, .required) + .field(FieldKeys.instructorID, .uuid, .required) + .field(FieldKeys.price, .double) + .field(FieldKeys.tags, .array(of: .uuid)) + .field(FieldKeys.categories, .array(of: .uuid)) + .field(FieldKeys.createdAt, .datetime) + .field(FieldKeys.updatedAt, .datetime) + .field(FieldKeys.publishDate, .datetime) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(CampusModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/CategoryModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/CategoryModel.swift new file mode 100644 index 0000000..a2b2e8a --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/CategoryModel.swift @@ -0,0 +1,57 @@ +// +// CategoryModel.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +import Fluent + +final class CategoryModel: Model, @unchecked Sendable { + static let schema: String = Database.categories.rawValue + + // MARK: - ID + + @ID(key: .id) + var id: UUID? + + // MARK: - Data + + @Field(key: FieldKeys.name) + var name: String + + // MARK: - Initializers + + init() {} + + init( + id: UUID? = nil, + name: String + ) { + self.id = id + self.name = name + } +} + +extension CategoryModel { + struct FieldKeys { + static var name: FieldKey { "name" } + } +} + +extension CategoryModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(CategoryModel.schema) + .id() + .field(FieldKeys.name, .string, .required) + .unique(on: FieldKeys.name) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(CategoryModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/ConfirmationModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/ConfirmationModel.swift new file mode 100644 index 0000000..ed3105b --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/ConfirmationModel.swift @@ -0,0 +1,78 @@ +// +// ConfirmationModel.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +import Fluent +import Vapor + +import Fluent + +final class ConfirmationModel: Model, @unchecked Sendable { + static let schema: String = Database.confirmations.rawValue + + @ID(key: .id) + var id: UUID? + + @Field(key: FieldKeys.userID) + var userID: UserModel.IDValue + + @Field(key: FieldKeys.confirmationCode) + var confirmationCode: String + + @Field(key: FieldKeys.email) + var email: String + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: FieldKeys.validTill, on: .none) + var validTill: Date? + + @Field(key: FieldKeys.isConfirmed) + var isConfirmed: Bool + + init() {} + + init(id: UUID? = nil, userID: UserModel.IDValue, email: String?, confirmationCode: String, validTill: Date, isConfirmed: Bool = false) { + self.id = id + self.userID = userID + self.confirmationCode = confirmationCode + self.validTill = validTill + self.isConfirmed = isConfirmed + } +} + +extension ConfirmationModel { + struct FieldKeys { + static var userID: FieldKey { "userID" } + static var confirmationCode: FieldKey { "confirmationCode" } + static var email: FieldKey { "email" } + static var createdAt: FieldKey { "createdAt" } + static var validTill: FieldKey { "validTill" } + static var isConfirmed: FieldKey { "isConfirmed" } + } +} + +extension ConfirmationModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(ConfirmationModel.schema) + .id() + .field(FieldKeys.userID, .uuid, .required) + .field(FieldKeys.confirmationCode, .string, .required) + .field(FieldKeys.email, .string, .required) + .field(FieldKeys.createdAt, .datetime) + .field(FieldKeys.validTill, .datetime) + .field(FieldKeys.isConfirmed, .bool, .required) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(ConfirmationModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/CourseModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/CourseModel.swift new file mode 100644 index 0000000..3b8b368 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/CourseModel.swift @@ -0,0 +1,103 @@ +// +// CourseModel.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +import Fluent + +final class CourseModel: Model, @unchecked Sendable { + static let schema: String = Database.courses.rawValue + + // MARK: - ID + + @ID(key: .id) + var id: UUID? + + // MARK: - Data + + @OptionalField(key: FieldKeys.title) + var title: String? + + @OptionalField(key: FieldKeys.description) + var description: String? + + @OptionalField(key: FieldKeys.slug) + var slug: String? + + @OptionalField(key: FieldKeys.excerpt) + var excerpt: String? + + @OptionalField(key: FieldKeys.content) + var content: String? + + @OptionalField(key: FieldKeys.campusID) + var campusID: CampusModel.IDValue? + + @Field(key: FieldKeys.authorID) + var authorID: UserModel.IDValue + + @OptionalField(key: FieldKeys.image) + var image: String? + + @Enum(key: FieldKeys.status) + var status: Status + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: FieldKeys.updatedAt, on: .update) + var updatedAt: Date? + + @OptionalField(key: FieldKeys.publishDate) + var publishDate: Date? + + // MARK: - Initializers + + init() {} +} + +extension CourseModel { + struct FieldKeys { + static var title: FieldKey { "title" } + static var description: FieldKey { "description" } + static var slug: FieldKey { "slug" } + static var excerpt: FieldKey { "excerpt" } + static var content: FieldKey { "content" } + static var campusID: FieldKey { "campusID" } + static var authorID: FieldKey { "authorID" } + static var image: FieldKey { "image" } + static var status: FieldKey { "status" } + static var createdAt: FieldKey { "createdAt" } + static var updatedAt: FieldKey { "updatedAt" } + static var publishDate: FieldKey { "publishDate" } + } +} + +extension CourseModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(CourseModel.schema) + .id() + .field(FieldKeys.title, .string) + .field(FieldKeys.description, .string) + .field(FieldKeys.slug, .string) + .field(FieldKeys.excerpt, .string) + .field(FieldKeys.content, .string) + .field(FieldKeys.campusID, .uuid) + .field(FieldKeys.authorID, .uuid, .required) + .field(FieldKeys.image, .string) + .field(FieldKeys.status, .string, .required) + .field(FieldKeys.createdAt, .datetime) + .field(FieldKeys.updatedAt, .datetime) + .field(FieldKeys.publishDate, .datetime) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(CourseModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/FileModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/FileModel.swift new file mode 100644 index 0000000..4fd68d0 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/FileModel.swift @@ -0,0 +1,79 @@ +// +// FileModel.swift +// ExodaiAcademy +// +// Created by Exodai on 24/01/2026. +// + +import Fluent + +final class FileModel: Model, @unchecked Sendable { + static let schema: String = Database.files.rawValue + + @ID(key: .id) + var id: UUID? + + @Field(key: FieldKeys.folderID) + var folderID: FolderModel.IDValue + + @Field(key: FieldKeys.name) + var name: String + + @Field(key: FieldKeys.type) + var type: String + + @Field(key: FieldKeys.mimeType) + var mimeType: String + + @Field(key: FieldKeys.storageKey) + var storageKey: String + + @Field(key: FieldKeys.size) + var size: Int64 + + @OptionalField(key: FieldKeys.metadata) + var metadata: String? + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + init() {} +} + +extension FileModel { + struct FieldKeys { + static var folderID: FieldKey { "folderID" } + static var name: FieldKey { "name" } + static var type: FieldKey { "type" } + static var mimeType: FieldKey { "mimeType" } + static var storageKey: FieldKey { "storageKey" } + static var size: FieldKey { "size" } + static var metadata: FieldKey { "metadata" } + static var createdAt: FieldKey { "createdAt" } + } +} + +import Fluent + +extension FileModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(FileModel.schema) + .id() + .field(FieldKeys.folderID, .uuid, .required) + .field(FieldKeys.name, .string, .required) + .field(FieldKeys.type, .string, .required) + .field(FieldKeys.mimeType, .string, .required) + .field(FieldKeys.storageKey, .string, .required) + .field(FieldKeys.size, .int64, .required) + .field(FieldKeys.metadata, .string) + .field(FieldKeys.createdAt, .datetime) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(FileModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/FolderModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/FolderModel.swift new file mode 100644 index 0000000..ad65141 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/FolderModel.swift @@ -0,0 +1,59 @@ +// +// FolderModel.swift +// ExodaiAcademy +// +// Created by Exodai on 24/01/2026. +// + +import Fluent + +final class FolderModel: Model, @unchecked Sendable { + static let schema: String = Database.folders.rawValue + + @ID(key: .id) + var id: UUID? + + @Field(key: FieldKeys.name) + var name: String + + @OptionalField(key: FieldKeys.parentFolderID) + var parentFolderID: FolderModel.IDValue? + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: FieldKeys.updatedAt, on: .update) + var updatedAt: Date? + + init() {} +} + +extension FolderModel { + struct FieldKeys { + static var name: FieldKey { "name" } + static var parentFolderID: FieldKey { "parentFolderID" } + static var createdAt: FieldKey { "createdAt" } + static var updatedAt: FieldKey { "updatedAt" } + } +} + +import Fluent + +extension FolderModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(FolderModel.schema) + .id() + .field(FieldKeys.name, .string, .required) + .field(FieldKeys.parentFolderID, .uuid) + .field(FieldKeys.createdAt, .datetime) + .field(FieldKeys.updatedAt, .datetime) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(FolderModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/LogModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/LogModel.swift new file mode 100644 index 0000000..94dbc37 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/LogModel.swift @@ -0,0 +1,72 @@ +// +// LogModel.swift +// ExodaiAcademy +// +// Created by Exodai on 24/01/2026. +// + +import Fluent + +final class LogModel: Model, @unchecked Sendable { + static let schema: String = Database.logs.rawValue + + @ID(key: .id) + var id: UUID? + + @Field(key: FieldKeys.event) + var event: String + + @OptionalField(key: FieldKeys.actorID) + var actorID: UserModel.IDValue? + + @OptionalField(key: FieldKeys.targetType) + var targetType: String? + + @OptionalField(key: FieldKeys.targetID) + var targetID: UUID? + + @OptionalField(key: FieldKeys.context) + var context: String? + + @OptionalField(key: FieldKeys.ipAddress) + var ipAddress: String? + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + init() {} +} + +extension LogModel { + struct FieldKeys { + static var event: FieldKey { "event" } + static var actorID: FieldKey { "actorID" } + static var ipAddress: FieldKey { "ipAddress" } + static var targetType: FieldKey { "targetType" } + static var targetID: FieldKey { "targetID" } + static var context: FieldKey { "context" } + static var createdAt: FieldKey { "createdAt" } + } +} + +extension LogModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(LogModel.schema) + .id() + .field(FieldKeys.event, .string, .required) + .field(FieldKeys.actorID, .uuid) + .field(FieldKeys.ipAddress, .string) + .field(FieldKeys.targetType, .string) + .field(FieldKeys.targetID, .uuid) + .field(FieldKeys.context, .string) + .field(FieldKeys.createdAt, .datetime) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(LogModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/PurchaseModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/PurchaseModel.swift new file mode 100644 index 0000000..0ce9e17 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/PurchaseModel.swift @@ -0,0 +1,83 @@ +// +// PurchaseModel.swift +// ExodaiAcademy +// +// Created by Exodai on 24/01/2026. +// + +import Fluent + +final class PurchaseModel: Model, @unchecked Sendable { + static let schema: String = Database.purchases.rawValue + + @ID(key: .id) + var id: UUID? + + @Field(key: FieldKeys.userID) + var userID: UserModel.IDValue + + @Field(key: FieldKeys.courseID) + var courseID: CourseModel.IDValue + + @Field(key: FieldKeys.pricePaid) + var pricePaid: Double + + @Field(key: FieldKeys.currency) + var currency: String + + @Field(key: FieldKeys.paymentProvider) + var paymentProvider: String + + @Field(key: FieldKeys.paymentReference) + var paymentReference: String + + @Timestamp(key: FieldKeys.purchasedAt, on: .create) + var purchasedAt: Date? + + @OptionalField(key: FieldKeys.refundedAt) + var refundedAt: Date? + + @Field(key: FieldKeys.grantedByAdmin) + var grantedByAdmin: Bool + + init() {} +} + +extension PurchaseModel { + struct FieldKeys { + static var userID: FieldKey { "userID" } + static var courseID: FieldKey { "courseID" } + static var pricePaid: FieldKey { "pricePaid" } + static var currency: FieldKey { "currency" } + static var paymentProvider: FieldKey { "paymentProvider" } + static var paymentReference: FieldKey { "paymentReference" } + static var purchasedAt: FieldKey { "purchasedAt" } + static var refundedAt: FieldKey { "refundedAt" } + static var grantedByAdmin: FieldKey { "grantedByAdmin" } + } +} + +extension PurchaseModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(PurchaseModel.schema) + .id() + .field(FieldKeys.userID, .uuid, .required) + .field(FieldKeys.courseID, .uuid, .required) + .field(FieldKeys.pricePaid, .double, .required) + .field(FieldKeys.currency, .string, .required) + .field(FieldKeys.paymentProvider, .string, .required) + .field(FieldKeys.paymentReference, .string, .required) + .field(FieldKeys.purchasedAt, .datetime) + .field(FieldKeys.refundedAt, .datetime) + .field(FieldKeys.grantedByAdmin, .bool, .required) + .unique(on: FieldKeys.userID, FieldKeys.courseID) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(PurchaseModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/SessionModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/SessionModel.swift new file mode 100644 index 0000000..dd41cd2 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/SessionModel.swift @@ -0,0 +1,100 @@ +// +// SessionModel.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +import Fluent + +final class SessionModel: Model, @unchecked Sendable { + static let schema: String = Database.sessions.rawValue + + // MARK: - ID + + @ID(key: .id) + var id: UUID? + + // MARK: - CMS Fields (ALL optional) + + @OptionalField(key: FieldKeys.title) + var title: String? + + @OptionalField(key: FieldKeys.description) + var description: String? + + @OptionalField(key: FieldKeys.slug) + var slug: String? + + @OptionalField(key: FieldKeys.mp4URL) + var mp4URL: String? + + @OptionalField(key: FieldKeys.hlsURL) + var hlsURL: String? + + @OptionalField(key: FieldKeys.content) + var content: String? + + @OptionalEnum(key: FieldKeys.status) + var status: Status? + + @OptionalField(key: FieldKeys.courseID) + var courseID: CourseModel.IDValue? + + // MARK: - Timestamps + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: FieldKeys.updatedAt, on: .update) + var updatedAt: Date? + + @OptionalField(key: FieldKeys.publishDate) + var publishDate: Date? + + // MARK: - Initializer + + init() {} +} + +extension SessionModel { + struct FieldKeys { + static var title: FieldKey { "title" } + static var description: FieldKey { "description" } + static var slug: FieldKey { "slug" } + static var mp4URL: FieldKey { "mp4URL" } + static var hlsURL: FieldKey { "hlsURL" } + static var content: FieldKey { "content" } + static var status: FieldKey { "status" } + static var courseID: FieldKey { "courseID" } + static var createdAt: FieldKey { "createdAt" } + static var updatedAt: FieldKey { "updatedAt" } + static var publishDate: FieldKey { "publishDate" } + } +} + +extension SessionModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(SessionModel.schema) + .id() + .field(FieldKeys.title, .string) + .field(FieldKeys.description, .string) + .field(FieldKeys.slug, .string) + .field(FieldKeys.mp4URL, .string) + .field(FieldKeys.hlsURL, .string) + .field(FieldKeys.content, .string) + .field(FieldKeys.status, .string) + .field(FieldKeys.courseID, .uuid) + .field(FieldKeys.createdAt, .datetime) + .field(FieldKeys.updatedAt, .datetime) + .field(FieldKeys.publishDate, .datetime) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(SessionModel.schema).delete() + } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/TagModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/TagModel.swift new file mode 100644 index 0000000..ba41230 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/TagModel.swift @@ -0,0 +1,31 @@ +// +// TagModel.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +import Fluent + +final class TagModel: Model, @unchecked Sendable { + static let schema: String = Database.tags.rawValue + + @ID(key: .id) + var id: UUID? + + @Field(key: FieldKeys.name) + var name: String + + init() {} + + init(id: UUID? = nil, name: String) { + self.id = id + self.name = name + } +} + +extension TagModel { + struct FieldKeys { + static var name: FieldKey { "name" } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/TokenModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/TokenModel.swift new file mode 100644 index 0000000..c0d8c14 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/TokenModel.swift @@ -0,0 +1,45 @@ +// +// TokenModel.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +import Fluent + +final class TokenModel: Model, @unchecked Sendable { + static let schema: String = Database.tokens.rawValue + + @ID(key: .id) + var id: UUID? + + @Field(key: FieldKeys.userID) + var userID: UserModel.IDValue + + @Field(key: FieldKeys.value) + var value: String + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: FieldKeys.validTill, on: .none) + var validTill: Date? + + init() {} + + init(id: UUID? = nil, userID: UserModel.IDValue, value: String, validTill: Date?) { + self.id = id + self.userID = userID + self.value = value + self.validTill = validTill + } +} + +extension TokenModel { + struct FieldKeys { + static var userID: FieldKey { "userID" } + static var value: FieldKey { "value" } + static var createdAt: FieldKey { "createdAt" } + static var validTill: FieldKey { "validTill" } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Database/UserModel.swift b/Sources/ExodaiAcademy/Infrastructure/Database/UserModel.swift new file mode 100644 index 0000000..6651574 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Database/UserModel.swift @@ -0,0 +1,177 @@ +// +// UserModel.swift +// ExodaiAcademy +// +// Created by Exodai on 22/01/2026. +// + +import Vapor +import Fluent +import FluentKit + +final class UserModel: Model, @unchecked Sendable { + static let schema: String = Database.users.rawValue + + @ID(key: .id) + var id: UUID? + + @Field(key: FieldKeys.username) + var username: String + + @Field(key: FieldKeys.email) + var email: String + + @Field(key: FieldKeys.password) + var password: String + + @Field(key: FieldKeys.role) + var role: Role + + @OptionalField(key: FieldKeys.name) + var name: String? + + @OptionalField(key: FieldKeys.lastname) + var lastname: String? + + @OptionalField(key: FieldKeys.address) + var address: String? + + @OptionalField(key: FieldKeys.zipCode) + var zipCode: String? + + @OptionalField(key: FieldKeys.city) + var city: String? + + @OptionalEnum(key: FieldKeys.country) + var Country: CountryCode? + + @OptionalField(key: FieldKeys.stripeID) + var stripeID: String? + + @Timestamp(key: FieldKeys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: FieldKeys.updatedAt, on: .update) + var updatedAt: Date? + + @Timestamp(key: FieldKeys.deletedAt, on: .delete) + var deletedAt: Date? + + @Timestamp(key: FieldKeys.lastLogin, on: .none) + var lastLogin: Date? + + init() { + + } + + init(id: UUID? = nil, username: String, email: String, password: String, role: Role) { + self.id = id + self.username = username + self.email = email + self.password = password + self.role = role + } +} + +extension UserModel { + struct FieldKeys { + static var username: FieldKey {"username"} + static var email: FieldKey {"email"} + static var password: FieldKey {"password"} + static var role: FieldKey {"role"} + static var name: FieldKey {"name"} + static var lastname: FieldKey {"lastname"} + static var address: FieldKey {"address"} + static var zipCode: FieldKey {"zipCode"} + static var city: FieldKey {"city"} + static var country: FieldKey {"country"} + static var stripeID: FieldKey {"stripeID"} + static var createdAt: FieldKey {"createdAt"} + static var updatedAt: FieldKey {"updatedAt"} + static var deletedAt: FieldKey {"deletedAt"} + static var lastLogin: FieldKey {"lastLogin"} + } +} + +import Fluent + +extension UserModel { + struct Migration: AsyncMigration { + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(UserModel.schema) + .id() + .field(FieldKeys.username, .string, .required) + .field(FieldKeys.email, .string, .required) + .field(FieldKeys.password, .string, .required) + .field(FieldKeys.role, .string, .required) + .field(FieldKeys.name, .string) + .field(FieldKeys.lastname, .string) + .field(FieldKeys.address, .string) + .field(FieldKeys.zipCode, .string) + .field(FieldKeys.city, .string) + .field(FieldKeys.country, .string) + .field(FieldKeys.stripeID, .string) + .field(FieldKeys.createdAt, .datetime) + .field(FieldKeys.updatedAt, .datetime) + .field(FieldKeys.deletedAt, .datetime) + .field(FieldKeys.lastLogin, .datetime) + .unique(on: FieldKeys.username) + .unique(on: FieldKeys.email) + .create() + } + + func revert(on database: any FluentKit.Database) async throws { + try await database.schema(UserModel.schema).delete() + } + } +} + +extension UserModel { + + struct Public: Content { + let id: UUID? + let username: String + let email: String + let role: Role + let name: String? + let lastname: String? + let address: String? + let zipCode: String? + let city: String? + let country: CountryCode? + let stripeID: String? + let createdAt: Date? + let updatedAt: Date? + let lastLogin: Date? + } +} + +extension UserModel { + + func convertToPublic() -> Public { + .init( + id: self.id, + username: self.username, + email: self.email, + role: self.role, + name: self.name, + lastname: self.lastname, + address: self.address, + zipCode: self.zipCode, + city: self.city, + country: self.Country, + stripeID: self.stripeID, + createdAt: self.createdAt, + updatedAt: self.updatedAt, + lastLogin: self.lastLogin + ) + } +} + +extension Collection where Element == UserModel { + + func convertToPublic() -> [UserModel.Public] { + self.map { $0.convertToPublic() } + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Repositories/User Repository/UserAuthRepository.swift b/Sources/ExodaiAcademy/Infrastructure/Repositories/User Repository/UserAuthRepository.swift new file mode 100644 index 0000000..4d43127 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Repositories/User Repository/UserAuthRepository.swift @@ -0,0 +1,66 @@ +// +// UserAuthRepository.swift +// ExodaiAcademy +// +// Created by Exodai on 25/01/2026. +// + +import Foundation + +protocol UserAuthRepository { + func findByEmail(_ email: String) async throws -> UserModel? + func findByUsername(_ username: String) async throws -> UserModel? + func create( + username: String, + email: String, + passwordHash: String, + role: Role + ) async throws -> UserModel + func updatePassword(userID: UUID, passwordHash: String) async throws + func delete(userID: UUID) async throws +} + +import Fluent +import Vapor + +struct FluentUserAuthRepository: UserAuthRepository { + let db: any FluentKit.Database + + func findByEmail(_ email: String) async throws -> UserModel? { + try await UserModel.query(on: db) + .filter(\.$email == email) + .first() + } + + func findByUsername(_ username: String) async throws -> UserModel? { + try await UserModel.query(on: db) + .filter(\.$username == username) + .first() + } + + func create(username: String, email: String, passwordHash: String, role: Role) async throws -> UserModel { + let user = UserModel( + username: username, + email: email, + password: passwordHash, + role: role + ) + + try await user.create(on: db) + return user + } + + func updatePassword(userID: UUID, passwordHash: String) async throws { + guard let user = try await UserModel.find(userID, on: db) else { + throw Abort(.notFound) + } + + user.password = passwordHash + try await user.save(on: db) + } + + func delete(userID: UUID) async throws { + try await UserModel.find(userID, on: db)? + .delete(on: db) + } +} diff --git a/Sources/ExodaiAcademy/Infrastructure/Repositories/User Repository/UserRepository.swift b/Sources/ExodaiAcademy/Infrastructure/Repositories/User Repository/UserRepository.swift new file mode 100644 index 0000000..d83fd47 --- /dev/null +++ b/Sources/ExodaiAcademy/Infrastructure/Repositories/User Repository/UserRepository.swift @@ -0,0 +1,46 @@ +// +// UserRepository.swift +// ExodaiAcademy +// +// Created by Exodai on 25/01/2026. +// + +import Foundation + +protocol UserRepository { + func find(id: UUID) async throws -> UserModel.Public? + func findByEmail(_ email: String) async throws -> UserModel.Public? + func findByUsername(_ username: String) async throws -> UserModel.Public? + func all() async throws -> [UserModel.Public] +} + +import Fluent + +struct FluentUserRepository: UserRepository { + let db: any FluentKit.Database + + func find(id: UUID) async throws -> UserModel.Public? { + try await UserModel.find(id, on: db)? + .convertToPublic() + } + + func findByEmail(_ email: String) async throws -> UserModel.Public? { + try await UserModel.query(on: db) + .filter(\.$email == email) + .first()? + .convertToPublic() + } + + func findByUsername(_ username: String) async throws -> UserModel.Public? { + try await UserModel.query(on: db) + .filter(\.$username == username) + .first()? + .convertToPublic() + } + + func all() async throws -> [UserModel.Public] { + try await UserModel.query(on: db) + .all() + .convertToPublic() + } +} diff --git a/Sources/ExodaiAcademy/Router/routes.swift b/Sources/ExodaiAcademy/Router/routes.swift new file mode 100644 index 0000000..164aab7 --- /dev/null +++ b/Sources/ExodaiAcademy/Router/routes.swift @@ -0,0 +1,6 @@ +import Fluent +import Vapor + +func routes(_ app: Application) throws { + +} diff --git a/Sources/ExodaiAcademy/configure.swift b/Sources/ExodaiAcademy/configure.swift new file mode 100644 index 0000000..1631c06 --- /dev/null +++ b/Sources/ExodaiAcademy/configure.swift @@ -0,0 +1,23 @@ +import NIOSSL +import Fluent +import FluentPostgresDriver +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/Sources/ExodaiAcademy/entrypoint.swift b/Sources/ExodaiAcademy/entrypoint.swift new file mode 100644 index 0000000..ad283b4 --- /dev/null +++ b/Sources/ExodaiAcademy/entrypoint.swift @@ -0,0 +1,31 @@ +import Vapor +import Logging +import NIOCore +import NIOPosix + +@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/Sources/Extensions/RandomConfirmationCode.swift b/Sources/Extensions/RandomConfirmationCode.swift new file mode 100644 index 0000000..eb0b6ec --- /dev/null +++ b/Sources/Extensions/RandomConfirmationCode.swift @@ -0,0 +1,23 @@ +// +// RandomConfirmationCode.swift +// ExodaiAcademy +// +// Created by Exodai on 23/01/2026. +// + +import Foundation + +extension String { + static func randomConfirmationCode(length: Int = 8) -> String { + let characters = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + var result = "" + result.reserveCapacity(length) + + for _ in 0.. ()) 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ec3be0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,72 @@ +# 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: &db_name vapor_database + DATABASE_USERNAME: &db_username vapor_username + DATABASE_PASSWORD: &db_password vapor_password + +services: + app: + image: exodai-academy: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: exodai-academy:latest + build: + context: . + environment: + <<: *shared_environment + depends_on: + - db + command: ["migrate", "--yes"] + deploy: + replicas: 0 + revert: + image: exodai-academy:latest + build: + context: . + environment: + <<: *shared_environment + depends_on: + - db + command: ["migrate", "--revert", "--yes"] + deploy: + replicas: 0 + db: + image: postgres:18-alpine + volumes: + - db_data:/var/lib/postgresql + environment: + POSTGRES_USER: *db_username + POSTGRES_PASSWORD: *db_password + POSTGRES_DB: *db_name + ports: + - '5432:5432'