diff --git a/Package.resolved b/Package.resolved index aa4bd68..75976f4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8af359d8d0bd04e1f21c9125fce09eb7d6a61a45a372b7485f4ecf4068fb7a53", + "originHash" : "5fd82be053af28c2638151b00890a2e86e39fa8f499795235ca85c4080aa9364", "pins" : [ { "identity" : "async-http-client", @@ -64,6 +64,24 @@ "version" : "4.8.0" } }, + { + "identity" : "hummingbird", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird.git", + "state" : { + "revision" : "7a41c20c25866064f22b2bfa2c8194083e7e1595", + "version" : "2.6.1" + } + }, + { + "identity" : "hummingbird-auth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird-auth.git", + "state" : { + "revision" : "8630a49acca3b38c50e29d61ab263cb7edf0b06d", + "version" : "2.0.2" + } + }, { "identity" : "leaf", "kind" : "remoteSourceControl", @@ -145,6 +163,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "4c3ea81f81f0a25d0470188459c6d4bf20cf2f97", + "version" : "1.0.3" + } + }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", @@ -208,6 +235,15 @@ "version" : "1.1.2" } }, + { + "identity" : "swift-extras-base64", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-extras/swift-extras-base64.git", + "state" : { + "revision" : "dc8121fdd2b444c97d6b0534e8ad4ddecbe0d5f4", + "version" : "1.0.0" + } + }, { "identity" : "swift-http-types", "kind" : "remoteSourceControl", @@ -298,6 +334,15 @@ "version" : "1.1.0" } }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "c2e97cf6f81510f2d6b4a69453861db65d478560", + "version" : "2.6.3" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 8d489b7..b621c08 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "hhe-po", platforms: [ - .macOS(.v13) + .macOS(.v14) ], dependencies: [ // 💧 A server-side Swift web framework. @@ -17,7 +17,8 @@ let package = Package( .package(url: "https://github.com/vapor/leaf.git", from: "4.3.0"), // 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), - .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.3") + .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.3"), + .package(url: "https://github.com/hummingbird-project/hummingbird-auth.git", from: "2.0.2") ], targets: [ .executableTarget( @@ -43,11 +44,30 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "DatabaseClient", + dependencies: [ + "SharedModels", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "Fluent", package: "fluent") + ], + swiftSettings: swiftSettings + ), + .target( + name: "DatabaseClientLive", + dependencies: [ + "DatabaseClient", + .product(name: "HummingbirdBcrypt", package: "hummingbird-auth") + ], + swiftSettings: swiftSettings + ), .target( name: "SharedModels", dependencies: [ .product(name: "Dependencies", package: "swift-dependencies") - ] + ], + swiftSettings: swiftSettings ) ], swiftLanguageModes: [.v5] diff --git a/Sources/DatabaseClient/Employees.swift b/Sources/DatabaseClient/Employees.swift new file mode 100644 index 0000000..32fb10b --- /dev/null +++ b/Sources/DatabaseClient/Employees.swift @@ -0,0 +1,63 @@ +import Dependencies +import DependenciesMacros +import SharedModels + +public extension DatabaseClient { + + @DependencyClient + struct Employees: Sendable { + public var create: @Sendable (Employee.Create) async throws -> Employee + public var delete: @Sendable (Employee.ID) async throws -> Void + public var fetchAll: @Sendable (FetchRequest) async throws -> [Employee] + public var get: @Sendable (Employee.ID) async throws -> Employee? + public var update: @Sendable (Employee.ID, Employee.Update) async throws -> Employee + + public func fetchAll() async throws -> [Employee] { + try await fetchAll(.all) + } + + public enum FetchRequest { + case active + case all + case inactive + } + } +} + +extension DatabaseClient.Employees: TestDependencyKey { + public static let testValue = Self() +} + +public extension Employee { + struct Create: Codable, Sendable { + public let firstName: String + public let lastName: String + public let active: Bool? + + public init( + firstName: String, + lastName: String, + active: Bool? = nil + ) { + self.firstName = firstName + self.lastName = lastName + self.active = active + } + } + + struct Update: Codable, Sendable { + public let firstName: String? + public let lastName: String? + public let active: Bool? + + public init( + firstName: String? = nil, + lastName: String? = nil, + active: Bool? = nil + ) { + self.firstName = firstName + self.lastName = lastName + self.active = active + } + } +} diff --git a/Sources/DatabaseClient/Interface.swift b/Sources/DatabaseClient/Interface.swift new file mode 100644 index 0000000..ea25512 --- /dev/null +++ b/Sources/DatabaseClient/Interface.swift @@ -0,0 +1,30 @@ +import Dependencies +import DependenciesMacros +import FluentKit + +public extension DependencyValues { + var database: DatabaseClient { + get { self[DatabaseClient.self] } + set { self[DatabaseClient.self] = newValue } + } +} + +@DependencyClient +public struct DatabaseClient: Sendable { + public var employees: Employees + public var migrations: @Sendable () async throws -> [any AsyncMigration] + public var purchaseOrders: PurchaseOrders + public var users: Users + public var vendorBranches: VendorBranches + public var vendors: Vendors +} + +extension DatabaseClient: TestDependencyKey { + public static let testValue: DatabaseClient = Self( + employees: .testValue, + purchaseOrders: .testValue, + users: .testValue, + vendorBranches: .testValue, + vendors: .testValue + ) +} diff --git a/Sources/DatabaseClient/PurchaseOrders.swift b/Sources/DatabaseClient/PurchaseOrders.swift new file mode 100644 index 0000000..84e33fb --- /dev/null +++ b/Sources/DatabaseClient/PurchaseOrders.swift @@ -0,0 +1,52 @@ +import Dependencies +import DependenciesMacros +import Fluent +import SharedModels + +public extension DatabaseClient { + @DependencyClient + struct PurchaseOrders: Sendable { + public var create: @Sendable (PurchaseOrder.Create, User.ID) async throws -> PurchaseOrder + public var fetchAll: @Sendable () async throws -> [PurchaseOrder] + public var fetchPage: @Sendable (PageRequest) async throws -> Page + public var get: @Sendable (PurchaseOrder.ID) async throws -> PurchaseOrder? + // var update: @Sendable (PurchaseOrder.ID, PurchaseOrder.Update) async throws -> PurchaseOrder + public var delete: @Sendable (PurchaseOrder.ID) async throws -> Void + } +} + +extension DatabaseClient.PurchaseOrders: TestDependencyKey { + public static let testValue: DatabaseClient.PurchaseOrders = Self() +} + +public extension PurchaseOrder { + struct Create: Codable, Sendable { + + public let id: Int? + public let workOrder: Int? + public let materials: String + public let customer: String + public let truckStock: Bool? + public let createdForID: Employee.ID + public let vendorBranchID: VendorBranch.ID + + public init( + id: Int? = nil, + workOrder: Int? = nil, + materials: String, + customer: String, + truckStock: Bool? = nil, + createdForID: Employee.ID, + vendorBranchID: VendorBranch.ID + ) { + self.id = id + self.workOrder = workOrder + self.materials = materials + self.customer = customer + self.truckStock = truckStock + self.createdForID = createdForID + self.vendorBranchID = vendorBranchID + } + } + +} diff --git a/Sources/DatabaseClient/Users.swift b/Sources/DatabaseClient/Users.swift new file mode 100644 index 0000000..a3e7e5c --- /dev/null +++ b/Sources/DatabaseClient/Users.swift @@ -0,0 +1,76 @@ +import Dependencies +import DependenciesMacros +import Foundation +import SharedModels + +public extension DatabaseClient { + + @DependencyClient + struct Users: Sendable { + public var create: @Sendable (User.Create) async throws -> User + public var delete: @Sendable (User.ID) async throws -> Void + public var fetchAll: @Sendable () async throws -> [User] + public var get: @Sendable (User.ID) async throws -> User? + public var login: @Sendable (User.Login) async throws -> User.Token + public var logout: @Sendable (User.Token.ID) async throws -> Void + } +} + +extension DatabaseClient.Users: TestDependencyKey { + public static let testValue: DatabaseClient.Users = Self() +} + +public extension User { + + struct Create: Codable, Sendable { + public let username: String + public let email: String + public let password: String + public let confirmPassword: String + + public init( + username: String, + email: String, + password: String, + confirmPassword: String + ) { + self.username = username + self.email = email + self.password = password + self.confirmPassword = confirmPassword + } + } + + struct Login: Codable, Sendable { + public let username: String? + public let email: String? + public let password: String + + public init( + username: String?, + email: String? = nil, + password: String + ) { + self.username = username + self.email = email + self.password = password + } + } + + struct Token: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let userID: User.ID + public let value: String + + public init( + id: UUID, + userID: User.ID, + value: String + ) { + self.id = id + self.userID = userID + self.value = value + } + } + +} diff --git a/Sources/DatabaseClient/VendorBranches.swift b/Sources/DatabaseClient/VendorBranches.swift new file mode 100644 index 0000000..8c4a062 --- /dev/null +++ b/Sources/DatabaseClient/VendorBranches.swift @@ -0,0 +1,48 @@ +import Dependencies +import DependenciesMacros +import SharedModels + +public extension DatabaseClient { + @DependencyClient + struct VendorBranches: Sendable { + public var create: @Sendable (VendorBranch.Create) async throws -> VendorBranch + public var delete: @Sendable (VendorBranch.ID) async throws -> Void + public var fetchAll: @Sendable (FetchRequest) async throws -> [VendorBranch] + public var get: @Sendable (VendorBranch.ID) async throws -> VendorBranch? + public var update: @Sendable (VendorBranch.ID, VendorBranch.Update) async throws -> VendorBranch + + public enum FetchRequest: Equatable { + case all + case `for`(vendorID: Vendor.ID) + case withVendor + } + + public func fetchAll() async throws -> [VendorBranch] { + try await fetchAll(.all) + } + } +} + +extension DatabaseClient.VendorBranches: TestDependencyKey { + public static let testValue: DatabaseClient.VendorBranches = Self() +} + +public extension VendorBranch { + struct Create: Codable, Sendable { + public let name: String + public let vendorID: Vendor.ID + + public init(name: String, vendorID: Vendor.ID) { + self.name = name + self.vendorID = vendorID + } + } + + struct Update: Codable, Sendable { + public let name: String? + + public init(name: String?) { + self.name = name + } + } +} diff --git a/Sources/DatabaseClient/Vendors.swift b/Sources/DatabaseClient/Vendors.swift new file mode 100644 index 0000000..2d60a84 --- /dev/null +++ b/Sources/DatabaseClient/Vendors.swift @@ -0,0 +1,55 @@ +import Dependencies +import DependenciesMacros +import SharedModels + +public extension DatabaseClient { + @DependencyClient + struct Vendors: Sendable { + public var create: @Sendable (Vendor.Create) async throws -> Vendor + public var delete: @Sendable (Vendor.ID) async throws -> Void + public var fetchAll: @Sendable (FetchRequest) async throws -> [Vendor] + public var get: @Sendable (Vendor.ID, GetRequest) async throws -> Vendor? + public var update: @Sendable (Vendor.ID, Vendor.Update) async throws -> Vendor + + public enum FetchRequest { + case all + case withBranches + } + + public enum GetRequest { + case all + case withBranches + } + + public func fetchAll() async throws -> [Vendor] { + try await fetchAll(.all) + } + + public func get(_ id: Vendor.ID) async throws -> Vendor? { + try await get(id, .all) + } + } +} + +extension DatabaseClient.Vendors: TestDependencyKey { + public static let testValue: DatabaseClient.Vendors = Self() +} + +public extension Vendor { + + struct Create: Codable, Sendable { + public let name: String + + public init(name: String) { + self.name = name + } + } + + struct Update: Codable, Sendable { + public let name: String? + + public init(name: String?) { + self.name = name + } + } +} diff --git a/Sources/DatabaseClientLive/Employees.swift b/Sources/DatabaseClientLive/Employees.swift new file mode 100644 index 0000000..f3d042b --- /dev/null +++ b/Sources/DatabaseClientLive/Employees.swift @@ -0,0 +1,176 @@ +import DatabaseClient +import Fluent +import Foundation +import SharedModels + +public extension DatabaseClient.Employees { + + static func live(database: any Database) -> DatabaseClient.Employees { + .init { create in + let model = try create.toModel() + try await model.save(on: database) + return model.toDTO() + } delete: { id in + guard let model = try await EmployeeModel.find(id, on: database) else { + throw NotFoundError() + } + try await model.delete(on: database) + } fetchAll: { request in + var query = EmployeeModel.query(on: database) + .sort(\.$lastName) + + switch request { + case .active: + query = query.filter(\.$active == true) + case .inactive: + query = query.filter(\.$active == false) + case .all: + break + } + + return try await query.all().map { $0.toDTO() } + + } get: { id in + try await EmployeeModel.find(id, on: database).map { $0.toDTO() } + } update: { id, updates in + guard let model = try await EmployeeModel.find(id, on: database) else { + throw NotFoundError() + } + try model.applyUpdate(updates) + try await model.save(on: database) + return model.toDTO() + } + } +} + +private extension Employee.Create { + + func toModel() throws -> EmployeeModel { + try validate() + return .init(firstName: firstName, lastName: lastName, active: active ?? true) + } + + func validate() throws { + guard !firstName.isEmpty else { + throw ValidationError(message: "Employee first name should not be empty.") + } + guard !lastName.isEmpty else { + throw ValidationError(message: "Employee first name should not be empty.") + } + } +} + +extension Employee.Update { + + func validate() throws { + if let firstName { + guard !firstName.isEmpty else { + throw ValidationError(message: "Employee first name should not be empty.") + } + } + if let lastName { + guard !lastName.isEmpty else { + throw ValidationError(message: "Employee first name should not be empty.") + } + } + } +} + +extension Employee { + + struct Migrate: AsyncMigration { + + let name = "CreateEmployee" + + func prepare(on database: Database) async throws { + try await database.schema(EmployeeModel.schema) + .id() + .field("first_name", .string, .required) + .field("last_name", .string, .required) + .field("is_active", .bool, .required) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .unique(on: "first_name", "last_name") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(EmployeeModel.schema).delete() + } + + } +} + +/// The employee database model. +/// +/// An employee is someone that PO's can be generated for. They can be either a field +/// employee / technician, an office employee, or an administrator. +/// +/// # NOTE: Only `User` types can login and generate po's for employees. +/// +final class EmployeeModel: Model, @unchecked Sendable { + + static let schema = "employee" + + // @ID(key: ") + // var id: UUID? + @ID(key: .id) + var id: UUID? + + @Field(key: "first_name") + var firstName: String + + @Field(key: "last_name") + var lastName: String + + @Field(key: "is_active") + var active: Bool + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + init() {} + + init( + id: UUID? = nil, + firstName: String, + lastName: String, + active: Bool, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.firstName = firstName + self.lastName = lastName + self.active = active + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + func toDTO() -> Employee { + .init( + id: id, + active: active, + createdAt: createdAt, + firstName: firstName, + lastName: lastName, + updatedAt: updatedAt + ) + } + + func applyUpdate(_ updates: Employee.Update) throws { + try updates.validate() + if let firstName = updates.firstName { + self.firstName = firstName + } + if let lastName = updates.lastName { + self.lastName = lastName + } + if let active = updates.active { + self.active = active + } + } +} diff --git a/Sources/DatabaseClientLive/Errors.swift b/Sources/DatabaseClientLive/Errors.swift new file mode 100644 index 0000000..d93af52 --- /dev/null +++ b/Sources/DatabaseClientLive/Errors.swift @@ -0,0 +1,5 @@ +public struct ValidationError: Error { + let message: String +} + +public struct NotFoundError: Error {} diff --git a/Sources/DatabaseClientLive/Live.swift b/Sources/DatabaseClientLive/Live.swift new file mode 100644 index 0000000..360aec0 --- /dev/null +++ b/Sources/DatabaseClientLive/Live.swift @@ -0,0 +1,27 @@ +import DatabaseClient +import FluentKit +import SharedModels + +public extension DatabaseClient { + + /// Create the live database client. + static func live(database: any Database) -> Self { + .init( + employees: .live(database: database), + migrations: { + [ + Employee.Migrate(), + PurchaseOrder.Migrate(), + User.Migrate(), + User.Token.Migrate(), + VendorBranch.Migrate(), + Vendor.Migrate() + ] + }, + purchaseOrders: .live(database: database), + users: .live(database: database), + vendorBranches: .live(database: database), + vendors: .live(database: database) + ) + } +} diff --git a/Sources/DatabaseClientLive/PurchaseOrders.swift b/Sources/DatabaseClientLive/PurchaseOrders.swift new file mode 100644 index 0000000..55892a7 --- /dev/null +++ b/Sources/DatabaseClientLive/PurchaseOrders.swift @@ -0,0 +1,174 @@ +import DatabaseClient +import FluentKit +import Foundation +import SharedModels + +public extension DatabaseClient.PurchaseOrders { + + static func live(database: any Database) -> Self { + .init { create, createdById in + let model = try create.toModel(createdByID: createdById) + try await model.save(on: database) + return try model.toDTO() + } fetchAll: { + try await PurchaseOrderModel.allQuery(on: database) + .all() + .map { try $0.toDTO() } + } fetchPage: { request in + try await PurchaseOrderModel.allQuery(on: database) + .paginate(request) + .map { try $0.toDTO() } + } get: { id in + try await PurchaseOrderModel.allQuery(on: database) + .filter(\.$id == id) + .first() + .map { try $0.toDTO() } + } delete: { id in + guard let model = try await PurchaseOrderModel.find(id, on: database) else { + throw NotFoundError() + } + try await model.delete(on: database) + } + } +} + +extension PurchaseOrder { + struct Migrate: AsyncMigration { + + let name = "CreatePurchaseOrder" + + func prepare(on database: any Database) async throws { + try await database.schema(PurchaseOrderModel.schema) + .field("id", .int, .identifier(auto: true)) + .field("work_order", .int) + .field("customer", .string, .required) + .field("materials", .string, .required) + .field("truck_stock", .bool, .required) + .field("created_by_id", .uuid, .required, .references(UserModel.schema, "id")) + .field("created_for_id", .uuid, .required, .references(EmployeeModel.schema, "id")) + .field("vendor_branch_id", .uuid, .required, .references(VendorBranchModel.schema, "id")) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(PurchaseOrderModel.schema).delete() + } + } + +} + +extension PurchaseOrder.Create { + + func toModel(createdByID: User.ID) throws -> PurchaseOrderModel { + try validate() + return .init( + materials: materials, + customer: customer, + truckStock: truckStock ?? false, + createdByID: createdByID, + createdForID: createdForID, + vendorBranchID: vendorBranchID + ) + } + + func validate() throws { + guard !materials.isEmpty else { + throw ValidationError(message: "Materials should not be empty.") + } + guard !customer.isEmpty else { + throw ValidationError(message: "Customer should not be empty.") + } + } +} + +/// The purchase order database model. +/// +/// # NOTE: An initial purchase order should be created with an `id` higher than our current PO +/// so that subsequent PO's are generated with higher values than our current system produces. +/// once the first one is set, the rest will auto-increment from there. +final class PurchaseOrderModel: Model, Codable, @unchecked Sendable { + static let schema = "purchase_order" + + @ID(custom: "id", generatedBy: .database) + var id: Int? + + @Field(key: "work_order") + var workOrder: Int? + + @Field(key: "materials") + var materials: String + + @Field(key: "customer") + var customer: String + + @Field(key: "truck_stock") + var truckStock: Bool + + @Parent(key: "created_by_id") + var createdBy: UserModel + + @Parent(key: "created_for_id") + var createdFor: EmployeeModel + + @Parent(key: "vendor_branch_id") + var vendorBranch: VendorBranchModel + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + init() {} + + init( + id: Int? = nil, + workOrder: Int? = nil, + materials: String, + customer: String, + truckStock: Bool, + createdByID: UserModel.IDValue, + createdForID: EmployeeModel.IDValue, + vendorBranchID: VendorBranchModel.IDValue, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.workOrder = workOrder + self.materials = materials + self.customer = customer + self.truckStock = truckStock + $createdBy.id = createdByID + $createdFor.id = createdForID + $vendorBranch.id = vendorBranchID + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + func toDTO() throws -> PurchaseOrder { + try .init( + id: requireID(), + workOrder: workOrder, + materials: materials, + customer: customer, + truckStock: truckStock, + createdBy: createdBy.toDTO(), + createdFor: createdFor.toDTO(), + vendorBranch: vendorBranch.toDTO(), + createdAt: createdAt, + updatedAt: updatedAt + ) + } + + static func allQuery(on db: any Database) -> QueryBuilder { + PurchaseOrderModel.query(on: db) + .sort(\.$id, .descending) + .with(\.$createdBy) + .with(\.$createdFor) + .with(\.$vendorBranch) { branch in + branch.with(\.$vendor) + } + } +} diff --git a/Sources/DatabaseClientLive/Users.swift b/Sources/DatabaseClientLive/Users.swift new file mode 100644 index 0000000..c62b4aa --- /dev/null +++ b/Sources/DatabaseClientLive/Users.swift @@ -0,0 +1,211 @@ +import DatabaseClient +import FluentKit +import Foundation +import HummingbirdBcrypt +import SharedModels + +public extension DatabaseClient.Users { + + static func live(database: any Database) -> Self { + .init { create in + let model = try create.toModel() + try await model.save(on: database) + return model.toDTO() + } delete: { id in + guard let model = try await UserModel.find(id, on: database) else { + throw NotFoundError() + } + try await model.delete(on: database) + } fetchAll: { + try await UserModel.query(on: database).all().map { $0.toDTO() } + } get: { id in + try await UserModel.find(id, on: database).map { $0.toDTO() } + } login: { login in + try login.validate() + + var query = UserModel.query(on: database) + + if let username = login.username { + query = query.filter(\.$username == username) + } else { + query = query.filter(\.$email == login.email!) + } + + guard let user = try await query.first() else { + throw NotFoundError() + } + + let token = try user.generateToken() + + try await token.save(on: database) + + return try User.Token( + id: token.requireID(), + userID: user.requireID(), + value: token.value + ) + + } logout: { id in + guard let token = try await UserTokenModel.find(id, on: database) + else { return } + try await token.delete(on: database) + } + } +} + +extension User { + struct Migrate: AsyncMigration { + let name = "CreateUser" + + func prepare(on database: any Database) async throws { + try await database.schema(UserModel.schema) + .id() + .field("username", .string, .required) + .field("email", .string, .required) + .field("password_hash", .string, .required) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .unique(on: "email", "username") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(UserModel.schema).delete() + } + } +} + +extension User.Token { + struct Migrate: AsyncMigration { + let name = "CreateUserToken" + + func prepare(on database: any Database) async throws { + try await database.schema(UserTokenModel.schema) + .id() + .field("value", .string, .required) + .field("user_id", .uuid, .required, .references(UserModel.schema, "id")) + .unique(on: "value") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(UserTokenModel.schema).delete() + } + } +} + +extension User.Create { + + func toModel() throws -> UserModel { + try validate() + return .init(username: username, email: email, passwordHash: Bcrypt.hash(password, cost: 12)) + } + + func validate() throws { + guard !username.isEmpty else { + throw ValidationError(message: "Username should not be empty.") + } + guard !email.isEmpty else { + throw ValidationError(message: "Email should not be empty") + } + guard password.count > 8 else { + throw ValidationError(message: "Password should be more than 8 characters long.") + } + guard password == confirmPassword else { + throw ValidationError(message: "Passwords do not match.") + } + } +} + +extension User.Login { + + func validate() throws { + guard username != nil || email != nil else { + throw ValidationError(message: "Either username or email must be provided to login.") + } + } +} + +/// The user database model. +/// +/// A user is someone who is able to login and generate PO's for employees. Generally a user should also +/// have an employee profile, but not all employees are users. Users are generally restricted to office workers +/// and administrators. +/// +/// +final class UserModel: Model, @unchecked Sendable { + static let schema = "user" + + @ID(key: .id) + var id: UUID? + + @Field(key: "username") + var username: String + + @Field(key: "email") + var email: String + + @Field(key: "password_hash") + var passwordHash: String + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + init() {} + + init( + id: UUID? = nil, + username: String, + email: String, + passwordHash: String + ) { + self.id = id + self.username = username + self.email = email + self.passwordHash = passwordHash + } + + func toDTO() -> User { + .init( + id: id, + createdAt: createdAt, + email: email, + updatedAt: updatedAt, + username: username + ) + } + + func generateToken() throws -> UserTokenModel { + try .init( + value: [UInt8].random(count: 16).base64, + userID: requireID() + ) + } + +} + +final class UserTokenModel: Model, Codable, @unchecked Sendable { + + static let schema = "user_token" + + @ID(key: .id) + var id: UUID? + + @Field(key: "value") + var value: String + + @Parent(key: "user_id") + var user: UserModel + + init() {} + + init(id: UUID? = nil, value: String, userID: UserModel.IDValue) { + self.id = id + self.value = value + $user.id = userID + } + +} diff --git a/Sources/DatabaseClientLive/VendorBranches.swift b/Sources/DatabaseClientLive/VendorBranches.swift new file mode 100644 index 0000000..9075b40 --- /dev/null +++ b/Sources/DatabaseClientLive/VendorBranches.swift @@ -0,0 +1,143 @@ +import DatabaseClient +import FluentKit +import Foundation +import SharedModels + +public extension DatabaseClient.VendorBranches { + + static func live(database db: any Database) -> Self { + .init { create in + let model = try create.toModel() + try await model.save(on: db) + return model.toDTO() + } delete: { id in + guard let model = try await VendorBranchModel.find(id, on: db) else { + throw NotFoundError() + } + try await model.delete(on: db) + } fetchAll: { request in + var query = VendorBranchModel.query(on: db) + + switch request { + case .all: + break + case .withVendor: + query = query.with(\.$vendor) + case let .for(vendorID: vendorID): + let branches = try await VendorModel.query(on: db) + .filter(\.$id == vendorID) + .with(\.$branches) + .first()? + .branches + .map { $0.toDTO() } + + guard let branches else { throw NotFoundError() } + return branches + } + + return try await query.all().map { $0.toDTO() } + } get: { id in + try await VendorBranchModel.find(id, on: db).map { $0.toDTO() } + } update: { id, updates in + guard let model = try await VendorBranchModel.find(id, on: db) else { + throw NotFoundError() + } + try model.applyUpdates(updates) + try await model.save(on: db) + return model.toDTO() + } + } + +} + +extension VendorBranch { + + struct Migrate: AsyncMigration { + let name = "CreateVendorBranch" + + func prepare(on database: Database) async throws { + try await database.schema(VendorBranchModel.schema) + .id() + .field("name", .string, .required) + .field("vendor_id", .uuid, .required) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .foreignKey("vendor_id", references: VendorModel.schema, "id", onDelete: .cascade) + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(VendorBranchModel.schema).delete() + } + } +} + +extension VendorBranch.Create { + + func toModel() throws -> VendorBranchModel { + try validate() + return .init(name: name, vendorId: vendorID) + } + + func validate() throws { + guard !name.isEmpty else { + throw ValidationError(message: "Vendor branch name should not be empty.") + } + } +} + +extension VendorBranch.Update { + func validate() throws { + if let name { + guard !name.isEmpty else { + throw ValidationError(message: "Vendor branch name should not be empty.") + } + } + } +} + +final class VendorBranchModel: Model, @unchecked Sendable { + + static let schema = "vendor_branch" + + @ID(key: .id) + var id: UUID? + + @Field(key: "name") + var name: String + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + @Parent(key: "vendor_id") + var vendor: VendorModel + + init() {} + + init(id: UUID? = nil, name: String, vendorId: Vendor.ID) { + self.id = id + self.name = name + $vendor.id = vendorId + } + + func toDTO() -> VendorBranch { + .init( + id: id, + name: name, + vendorID: $vendor.id, + createdAt: createdAt, + updatedAt: updatedAt + ) + } + + func applyUpdates(_ updates: VendorBranch.Update) throws { + try updates.validate() + if let name = updates.name { + self.name = name + } + } + +} diff --git a/Sources/DatabaseClientLive/Vendors.swift b/Sources/DatabaseClientLive/Vendors.swift new file mode 100644 index 0000000..f0001a7 --- /dev/null +++ b/Sources/DatabaseClientLive/Vendors.swift @@ -0,0 +1,140 @@ +import DatabaseClient +import FluentKit +import Foundation +import SharedModels + +public extension DatabaseClient.Vendors { + + static func live(database db: any Database) -> Self { + .init { create in + let model = try create.toModel() + try await model.save(on: db) + return model.toDTO() + } delete: { id in + guard let model = try await VendorModel.find(id, on: db) else { + throw NotFoundError() + } + try await model.delete(on: db) + } fetchAll: { request in + var query = VendorModel.query(on: db).sort(\.$name, .ascending) + + let withBranches = request == .withBranches + + switch request { + case .withBranches: + query = query.with(\.$branches) + case .all: + break + } + + return try await query.all().map { $0.toDTO(includeBranches: withBranches) } + + } get: { id, request in + var query = VendorModel.query(on: db).filter(\.$id == id) + let withBranches = request == .withBranches + if withBranches { + query = query.with(\.$branches) + } + return try await query.first().map { $0.toDTO(includeBranches: withBranches) } + } update: { id, updates in + guard let model = try await VendorModel.find(id, on: db) else { + throw NotFoundError() + } + try model.applyUpdates(updates) + try await model.save(on: db) + return model.toDTO() + } + } +} + +extension Vendor { + + struct Migrate: AsyncMigration { + let name = "CreateVendor" + + func prepare(on database: Database) async throws { + try await database.schema(VendorModel.schema) + .id() + .field("name", .string, .required) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .unique(on: "name") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(VendorModel.schema).delete() + } + } +} + +extension Vendor.Create { + + func toModel() throws -> VendorModel { + try validate() + return .init(name: name) + } + + func validate() throws { + guard !name.isEmpty else { + throw ValidationError(message: "Vendor name should not be empty.") + } + } +} + +extension Vendor.Update { + func validate() throws { + if let name { + guard !name.isEmpty else { + throw ValidationError(message: "Vendor name should not be empty.") + } + } + } +} + +// The primary database model. +final class VendorModel: Model, @unchecked Sendable { + + static let schema = "vendor" + + @ID(key: .id) + var id: UUID? + + @Field(key: "name") + var name: String + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + @Children(for: \.$vendor) + var branches: [VendorBranchModel] + + init() {} + + init(id: UUID? = nil, name: String) { + self.id = id + self.name = name + } + + func toDTO(includeBranches: Bool? = nil) -> Vendor { + .init( + id: id, + name: name, + branches: ($branches.value != nil && $branches.value!.count > 0) + ? $branches.value!.map { $0.toDTO() } + : [], + createdAt: createdAt, + updatedAt: updatedAt + ) + } + + func applyUpdates(_ updates: Vendor.Update) throws { + try updates.validate() + if let name = updates.name { + self.name = name + } + } +} diff --git a/Sources/SharedModels/Employee.swift b/Sources/SharedModels/Employee.swift index a761d6e..775de64 100644 --- a/Sources/SharedModels/Employee.swift +++ b/Sources/SharedModels/Employee.swift @@ -1,7 +1,7 @@ import Dependencies import Foundation -public struct Employee: Equatable, Identifiable, Sendable { +public struct Employee: Codable, Equatable, Identifiable, Sendable { public var id: UUID public var active: Bool public var createdAt: Date @@ -10,7 +10,7 @@ public struct Employee: Equatable, Identifiable, Sendable { public var updatedAt: Date public init( - id: UUID?, + id: UUID? = nil, active: Bool = true, createdAt: Date? = nil, firstName: String, @@ -22,10 +22,20 @@ public struct Employee: Equatable, Identifiable, Sendable { self.id = id ?? uuid() self.active = active - self.createdAt = createdAt ?? date() + self.createdAt = createdAt ?? date.now self.firstName = firstName self.lastName = lastName - self.updatedAt = updatedAt ?? date() + self.updatedAt = updatedAt ?? date.now } } + +public extension Employee { + static var mocks: [Self] { + [ + .init(firstName: "Michael", lastName: "Housh"), + .init(firstName: "Blob", lastName: "Esquire"), + .init(firstName: "Testy", lastName: "McTestface") + ] + } +} diff --git a/Sources/SharedModels/PurchaseOrder.swift b/Sources/SharedModels/PurchaseOrder.swift new file mode 100644 index 0000000..0ccb163 --- /dev/null +++ b/Sources/SharedModels/PurchaseOrder.swift @@ -0,0 +1,40 @@ +import Dependencies +import Foundation + +public struct PurchaseOrder: Codable, Equatable, Identifiable, Sendable { + + public let id: Int + public var workOrder: Int? + public var materials: String + public var customer: String + public var truckStock: Bool + public var createdBy: User + public var createdFor: Employee + public var vendorBranch: VendorBranch + public var createdAt: Date? + public var updatedAt: Date? + + public init( + id: Int, + workOrder: Int? = nil, + materials: String, + customer: String, + truckStock: Bool, + createdBy: User, + createdFor: Employee, + vendorBranch: VendorBranch, + createdAt: Date?, + updatedAt: Date? + ) { + self.id = id + self.workOrder = workOrder + self.materials = materials + self.customer = customer + self.truckStock = truckStock + self.createdBy = createdBy + self.createdFor = createdFor + self.vendorBranch = vendorBranch + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/Sources/SharedModels/User.swift b/Sources/SharedModels/User.swift index 54500cc..a28af0f 100644 --- a/Sources/SharedModels/User.swift +++ b/Sources/SharedModels/User.swift @@ -1,7 +1,7 @@ import Dependencies import Foundation -public struct User: Identifiable, Codable, Sendable { +public struct User: Codable, Equatable, Identifiable, Sendable { public var id: UUID public var createdAt: Date @@ -20,9 +20,9 @@ public struct User: Identifiable, Codable, Sendable { @Dependency(\.uuid) var uuid self.id = id ?? uuid() - self.createdAt = createdAt ?? date() + self.createdAt = createdAt ?? date.now self.email = email - self.updatedAt = updatedAt ?? date() + self.updatedAt = updatedAt ?? date.now self.username = username } } diff --git a/Sources/SharedModels/Vendor.swift b/Sources/SharedModels/Vendor.swift index 2403461..28bd93f 100644 --- a/Sources/SharedModels/Vendor.swift +++ b/Sources/SharedModels/Vendor.swift @@ -1,24 +1,38 @@ import Dependencies import Foundation -public struct Vendor: Identifiable, Equatable, Sendable { +public struct Vendor: Codable, Equatable, Identifiable, Sendable { public var id: UUID - public var createdAt: Date public var name: String + public var branches: [VendorBranch]? + public var createdAt: Date public var updatedAt: Date public init( id: UUID? = nil, - createdAt: Date? = nil, name: String, + branches: [VendorBranch]? = nil, + createdAt: Date? = nil, updatedAt: Date? = nil ) { @Dependency(\.date) var date @Dependency(\.uuid) var uuid self.id = id ?? uuid() - self.createdAt = createdAt ?? date() self.name = name - self.updatedAt = updatedAt ?? date() + self.branches = branches + self.createdAt = createdAt ?? date.now + self.updatedAt = updatedAt ?? date.now + } +} + +public extension Vendor { + + static var mocks: [Self] { + [ + .init(name: "Corken"), + .init(name: "Johnstone"), + .init(name: "Winstel Controls") + ] } } diff --git a/Sources/SharedModels/VendorBranch.swift b/Sources/SharedModels/VendorBranch.swift index e69de29..4fa2689 100644 --- a/Sources/SharedModels/VendorBranch.swift +++ b/Sources/SharedModels/VendorBranch.swift @@ -0,0 +1,27 @@ +import Dependencies +import Foundation + +public struct VendorBranch: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var vendorID: Vendor.ID + public var createdAt: Date + public var updatedAt: Date + + public init( + id: UUID? = nil, + name: String, + vendorID: Vendor.ID, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + @Dependency(\.date) var date + @Dependency(\.uuid) var uuid + + self.id = id ?? uuid() + self.name = name + self.vendorID = vendorID + self.createdAt = createdAt ?? date.now + self.updatedAt = updatedAt ?? date.now + } +}