Initial commit

This commit is contained in:
Johan
2026-01-26 00:37:35 +01:00
commit f303cb46b7
30 changed files with 2251 additions and 0 deletions

91
Dockerfile Normal file
View File

@@ -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"]

312
Package.resolved Normal file
View File

@@ -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
}

44
Package.swift Normal file
View File

@@ -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"),
] }

0
Public/.gitkeep Normal file
View File

27
README.md Normal file
View File

@@ -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)

View File

@@ -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 dIvoire
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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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" }
}
}

View File

@@ -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" }
}
}

View File

@@ -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() }
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,6 @@
import Fluent
import Vapor
func routes(_ app: Application) throws {
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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..<length {
let index = Int.random(in: 0..<characters.count)
result.append(characters[index])
}
return result
}
}

View File

@@ -0,0 +1,84 @@
@testable import ExodaiAcademy
import VaporTesting
import Testing
import Fluent
@Suite("App Tests with DB", .serialized)
struct ExodaiAcademyTests {
private func withApp(_ test: (Application) async throws -> ()) 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
}
}

72
docker-compose.yml Normal file
View File

@@ -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'