From 1ce369e1560a222fb37d725e899e6e8090dccc13 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Sun, 12 Jan 2025 17:42:06 -0500 Subject: [PATCH] feat: working on detail views. --- Public/css/main.css | 84 +++++++++++++++++-- Resources/Views/employees/detail.leaf | 63 ++++++++++++++ Resources/Views/employees/index.leaf | 1 + Resources/Views/employees/table-row.leaf | 43 ++++++---- Resources/Views/index.leaf | 4 +- Resources/Views/purchaseOrders/detail.leaf | 32 ++++--- Resources/Views/purchaseOrders/index.leaf | 10 +++ Resources/Views/purchaseOrders/table-row.leaf | 4 +- Resources/Views/users/detail.leaf | 36 ++++++++ Resources/Views/users/index.leaf | 2 + Resources/Views/users/table-row.leaf | 14 ++++ Resources/Views/users/table.leaf | 15 +--- .../Api/EmployeeApiController.swift | 2 +- .../Api/VendorBranchApiController.swift | 2 +- .../View/EmployeeViewController.swift | 67 +++++++++++---- .../View/PurchaseOrderViewController.swift | 28 +++++-- .../View/UsersViewController.swift | 45 +++++++--- Sources/App/DB/EmployeeDB.swift | 15 ++-- Sources/App/DB/UserDB.swift | 4 + Sources/App/DB/VendorBranchDB.swift | 43 ++++++---- .../Request+ensureValidContent.swift | 8 -- .../App/Extensions/Request+extensions.swift | 22 +++++ .../Extensions/RouteBuilder+protected.swift | 32 ++++--- Sources/App/Models/Employee.swift | 31 ++++++- Sources/App/Models/User.swift | 25 +++++- Sources/App/Models/Vendor.swift | 14 +++- Sources/App/Models/VendorBranch.swift | 18 +++- 27 files changed, 527 insertions(+), 137 deletions(-) create mode 100644 Resources/Views/employees/detail.leaf create mode 100644 Resources/Views/users/detail.leaf create mode 100644 Resources/Views/users/table-row.leaf delete mode 100644 Sources/App/Extensions/Request+ensureValidContent.swift create mode 100644 Sources/App/Extensions/Request+extensions.swift diff --git a/Public/css/main.css b/Public/css/main.css index 7a519f5..fd1b31d 100644 --- a/Public/css/main.css +++ b/Public/css/main.css @@ -147,13 +147,17 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus { background-color: #555; } -.toggle img { +.toggle, .toggle img { background-color: inherit; width: 60px; height: 30px; +} + +a.toggle, a img.toggle { cursor: pointer; } + .toggle img:hover { background-color: #555; } @@ -221,13 +225,15 @@ tr.htmx-swapping td { overflow: auto; z-index: 1; position: fixed; - top: 100px; + top: 60px; left: 0; background-color: #14141f; width: 100%; } .closebtn { + border: none; + background-color: inherit; color: grey; margin-left: 50px; text-decoration: none; @@ -309,10 +315,7 @@ tr.htmx-swapping td { background-color: inherit; font-size: 1.3em; padding-bottom: 10px; -} - -.btn-row button:hover { - color: blue; + cursor: pointer; } .btn-detail { @@ -336,3 +339,72 @@ tr.htmx-swapping td { .label { color: #00ffcc; } + +.float { + z-index: 1; + position: absolute; + top: 60px; + left: 0; + width: 100%; + background-color: #14141f; + padding: 20px; +} + +.float .closebtn { + position: relative; + float: right; + top: 0; + right: 5px; + font-size: 36px; + margin-left: 50px; + color: grey; +} + +.float .closebtn:hover { + color: white; +} + +.float.htmx-swapping { + opacity: 0; + transition: opacity 0.5s ease-out; +} + +.float table { + position: relative; + top: 15px; +} + +.btn-row { + margin-top: 40px; + margin-right: 5px; +} + +.btn-row button { + float: right; + padding: 10px 20px; + border-radius: 10px; + margin-left: 20px; +} + +button.danger { + background-color: #ff4d4d; + color: white; +} + +button.edit { + float: right; + background-color: #9999ff; + color: white; + } + +.danger:hover, .edit:hover { + opacity: 0.8; +} + +form table input[type=text] { + border: none; + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; + font-size: 1.5em; +} diff --git a/Resources/Views/employees/detail.leaf b/Resources/Views/employees/detail.leaf new file mode 100644 index 0000000..eff9b3c --- /dev/null +++ b/Resources/Views/employees/detail.leaf @@ -0,0 +1,63 @@ + diff --git a/Resources/Views/employees/index.leaf b/Resources/Views/employees/index.leaf index 36336fb..d99ea03 100644 --- a/Resources/Views/employees/index.leaf +++ b/Resources/Views/employees/index.leaf @@ -7,6 +7,7 @@

Employees are who purchase orders can be generated for.


+ #extend("employees/detail") #extend("form-container"): #export("formContent"): #extend("employees/form", form) #endexport #endextend diff --git a/Resources/Views/employees/table-row.leaf b/Resources/Views/employees/table-row.leaf index e7c8d4d..84e9260 100644 --- a/Resources/Views/employees/table-row.leaf +++ b/Resources/Views/employees/table-row.leaf @@ -19,22 +19,33 @@ #endif - - - #extend("img/trash-can") - - - #extend("img/pencil") - + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/Views/index.leaf b/Resources/Views/index.leaf index ed171e0..ce7efa7 100644 --- a/Resources/Views/index.leaf +++ b/Resources/Views/index.leaf @@ -4,8 +4,8 @@ - - + + #(title) diff --git a/Resources/Views/purchaseOrders/detail.leaf b/Resources/Views/purchaseOrders/detail.leaf index 64db5ce..e3bd857 100644 --- a/Resources/Views/purchaseOrders/detail.leaf +++ b/Resources/Views/purchaseOrders/detail.leaf @@ -1,46 +1,56 @@ -
+
+ #if(purchaseOrderDetail): + + × + - + - + - + - + - + - + - + - + - + - +

Purchase Order:

#(purchaseOrder.id)

#(purchaseOrderDetail.id)

Work Order:

#(purchaseOrder.workOrder)

#(purchaseOrderDetail.workOrder)

Customer:

#(purchaseOrder.customer)

#(purchaseOrderDetail.customer)

Vendor:

#capitalized(purchaseOrder.vendorBranch.vendor.name) - #capitalized(purchaseOrder.vendorBranch.name)

#capitalized(purchaseOrderDetail.vendorBranch.vendor.name) - #capitalized(purchaseOrderDetail.vendorBranch.name)

Materials:

#(purchaseOrder.materials)

#(purchaseOrderDetail.materials)

Created For:

#capitalized(purchaseOrder.createdFor.firstName) #capitalized(purchaseOrder.createdFor.lastName)

#capitalized(purchaseOrderDetail.createdFor.firstName) #capitalized(purchaseOrderDetail.createdFor.lastName)

Truck Stock:

#capitalized(purchaseOrder.truckStock)

#capitalized(purchaseOrderDetail.truckStock)

Created By:

#(purchaseOrder.createdBy.username)

#(purchaseOrderDetail.createdBy.username)

Date:

#date(purchaseOrder.createdAt)

#date(purchaseOrderDetail.createdAt, "MM-dd-yyyy")

Updated:

#date(purchaseOrder.updatedAt)

#date(purchaseOrderDetail.updatedAt, "MM-dd-yyyy")

+ #endif
diff --git a/Resources/Views/purchaseOrders/index.leaf b/Resources/Views/purchaseOrders/index.leaf index f35cf8f..c32f2b6 100644 --- a/Resources/Views/purchaseOrders/index.leaf +++ b/Resources/Views/purchaseOrders/index.leaf @@ -8,6 +8,16 @@ #extend("form-container"): #export("formContent"): #extend("purchaseOrders/form", form) #endexport #endextend +
+
#if(hasPrevious): diff --git a/Resources/Views/users/detail.leaf b/Resources/Views/users/detail.leaf new file mode 100644 index 0000000..c13d52c --- /dev/null +++ b/Resources/Views/users/detail.leaf @@ -0,0 +1,36 @@ + diff --git a/Resources/Views/users/index.leaf b/Resources/Views/users/index.leaf index 0233428..87bb9c8 100644 --- a/Resources/Views/users/index.leaf +++ b/Resources/Views/users/index.leaf @@ -7,9 +7,11 @@

Users are people that can login and generate puchase orders for employees.


+ #extend("users/detail") #extend("form-container"): #export("formContent"): #extend("users/form", form) #endexport #endextend + #extend("users/table")
#endexport diff --git a/Resources/Views/users/table-row.leaf b/Resources/Views/users/table-row.leaf new file mode 100644 index 0000000..1baf71b --- /dev/null +++ b/Resources/Views/users/table-row.leaf @@ -0,0 +1,14 @@ + + #(username) + #(email) + + + + diff --git a/Resources/Views/users/table.leaf b/Resources/Views/users/table.leaf index e57da91..6b69087 100644 --- a/Resources/Views/users/table.leaf +++ b/Resources/Views/users/table.leaf @@ -8,20 +8,7 @@ #for(user in users): - - #(user.username) - #(user.email) - - - Delete - - - + #extend("users/table-row", user) #endfor diff --git a/Sources/App/Controllers/Api/EmployeeApiController.swift b/Sources/App/Controllers/Api/EmployeeApiController.swift index b7d21a6..d2f9f70 100644 --- a/Sources/App/Controllers/Api/EmployeeApiController.swift +++ b/Sources/App/Controllers/Api/EmployeeApiController.swift @@ -20,7 +20,7 @@ struct EmployeeApiController: RouteCollection { @Sendable func index(req: Request) async throws -> [Employee.DTO] { let params = try req.query.decode(EmployeesIndexQuery.self) - return try await employees.fetchAll(params.active ?? false) + return try await employees.fetchAll(params.active == true ? .active : .default) } @Sendable diff --git a/Sources/App/Controllers/Api/VendorBranchApiController.swift b/Sources/App/Controllers/Api/VendorBranchApiController.swift index cd2dacf..b362ac5 100644 --- a/Sources/App/Controllers/Api/VendorBranchApiController.swift +++ b/Sources/App/Controllers/Api/VendorBranchApiController.swift @@ -31,7 +31,7 @@ struct VendorBranchApiController: RouteCollection { guard let id = req.parameters.get("vendorID", as: Vendor.IDValue.self) else { throw Abort(.badRequest, reason: "Vendor id not provided.") } - return try await vendorBranches.fetchForVendor(id) + return try await vendorBranches.fetchAll(.for(vendorID: id)) } @Sendable diff --git a/Sources/App/Controllers/View/EmployeeViewController.swift b/Sources/App/Controllers/View/EmployeeViewController.swift index 492cb1b..d1dab79 100644 --- a/Sources/App/Controllers/View/EmployeeViewController.swift +++ b/Sources/App/Controllers/View/EmployeeViewController.swift @@ -13,7 +13,8 @@ struct EmployeeViewController: RouteCollection { protected.get("form", use: employeeForm(req:)) protected.post(use: create(req:)) protected.group(":employeeID") { - $0.get(use: edit(req:)) + $0.get(use: get(req:)) + $0.get("edit", use: edit(req:)) $0.delete(use: delete(req:)) $0.put(use: update(req:)) $0.patch("toggle-active", use: toggleActive(req:)) @@ -22,15 +23,36 @@ struct EmployeeViewController: RouteCollection { @Sendable func index(req: Request) async throws -> View { - return try await req.view.render("employees/index", EmployeesCTX(db: employees)) + return try await renderIndex(req) + } + + @Sendable + private func renderIndex( + _ req: Request, + _ employee: Employee.DTO? = nil, + _ form: EmployeeFormCTX? = nil + ) async throws -> View { + return try await req.view.render( + "employees/index", + EmployeesCTX(employee: employee, employees: employees.fetchAll(), form: form ?? .init()) + ) } @Sendable func create(req: Request) async throws -> View { try Employee.Create.validate(content: req) - let model = try req.content.decode(Employee.Create.self) - _ = try await employees.create(model) - return try await req.view.render("employees/index", EmployeesCTX(oob: true, db: employees)) + let employee = try await employees.create(req.content.decode(Employee.Create.self)) + return try await req.view.render("employees/table-row", employee) + } + + @Sendable + func get(req: Request) async throws -> View { + let employee = try await employees.get(req.ensureIDPathComponent(key: "employeeID")) + // Check if we've rendered the page yet. + guard req.isHtmxRequest else { + return try await renderIndex(req, employee) + } + return try await req.view.render("employees/detail", ["employee": employee]) } @Sendable @@ -42,6 +64,7 @@ struct EmployeeViewController: RouteCollection { return try await req.view.render("employees/table-row", employee) } + // TODO: I think we can just return a response and remove the table-row, here. @Sendable func delete(req: Request) async throws -> View { let id = try req.requireEmployeeID() @@ -55,7 +78,7 @@ struct EmployeeViewController: RouteCollection { guard let employee = try await employees.get(req.parameters.get("employeeID")) else { throw Abort(.notFound) } - return try await req.view.render("employees/form", EmployeeFormCTX(employee: employee)) + return try await req.view.render("employees/detail", EmployeeDetailCTX(editing: true, employee: employee)) } @Sendable @@ -63,8 +86,10 @@ struct EmployeeViewController: RouteCollection { let id = try req.requireEmployeeID() try Employee.Update.validate(content: req) let updates = try req.content.decode(Employee.Update.self) - _ = try await employees.update(id, updates) - return try await req.view.render("employees/index", EmployeesCTX(oob: true, db: employees)) + req.logger.info("Employee updates: \(updates)") + let employee = try await employees.update(id, updates) + req.logger.info("Done updating employee: \(employee)") + return try await req.view.render("employees/table-row", employee) } @Sendable @@ -83,19 +108,29 @@ private extension Request { } } +private struct EmployeeDetailCTX: Content { + let editing: Bool + let employee: Employee.DTO? + + init(editing: Bool = false, employee: Employee.DTO? = nil) { + self.editing = editing + self.employee = employee + } +} + private struct EmployeesCTX: Content { - let oob: Bool + let employee: Employee.DTO? let employees: [Employee.DTO] let form: EmployeeFormCTX init( - oob: Bool = false, - employee: Employee? = nil, - db: EmployeeDB - ) async throws { - self.oob = oob - self.employees = try await db.fetchAll() - self.form = .init(employee: employee.map { $0.toDTO() }) + employee: Employee.DTO? = nil, + employees: [Employee.DTO], + form: EmployeeFormCTX? = nil + ) { + self.employee = employee + self.employees = employees + self.form = form ?? .init() } } diff --git a/Sources/App/Controllers/View/PurchaseOrderViewController.swift b/Sources/App/Controllers/View/PurchaseOrderViewController.swift index dd1e26c..c99311d 100644 --- a/Sources/App/Controllers/View/PurchaseOrderViewController.swift +++ b/Sources/App/Controllers/View/PurchaseOrderViewController.swift @@ -3,16 +3,17 @@ import Fluent import Vapor struct PurchaseOrderViewController: RouteCollection { + @Dependency(\.employees) var employees @Dependency(\.purchaseOrders) var purchaseOrders - - private let employeesApi = EmployeeApiController() - private let branches = VendorBranchDB() - private let api = ApiController() + @Dependency(\.vendorBranches) var vendorBranches func boot(routes: any RoutesBuilder) throws { let pos = routes.protected.grouped("purchase-orders") pos.get(use: index(req:)) + pos.group("details", "close") { + $0.get(use: detailClose(req:)) + } pos.post(use: create(req:)) pos.group(":id") { $0.get(use: detail(req:)) @@ -25,8 +26,8 @@ struct PurchaseOrderViewController: RouteCollection { let purchaseOrdersPage = try await purchaseOrders.fetchPage( .init(page: params?.page ?? 1, per: params?.limit ?? 50) ) - let branches = try await self.branches.getBranches(req: req) - let employees = try await employeesApi.index(req: req) + let branches = try await vendorBranches.getBranches(req: req) + let employees = try await employees.fetchAll() req.logger.debug("Branches: \(branches)") return try await req.view.render( "purchaseOrders/index", @@ -43,7 +44,12 @@ struct PurchaseOrderViewController: RouteCollection { throw Abort(.badRequest, reason: "Id not supplied.") } let purchaseOrder = try await purchaseOrders.get(id) - return try await req.view.render("purchaseOrders/detail", ["purchaseOrder": purchaseOrder]) + return try await req.view.render("purchaseOrders/detail", ["purchaseOrderDetail": purchaseOrder]) + } + + @Sendable + func detailClose(req: Request) async throws -> View { + return try await req.view.render("purchaseOrders/detail") } @Sendable @@ -62,6 +68,7 @@ private struct PurchaseOrderIndex: Content { } private struct PurchaseOrderCTX: Content { + let purchaseOrderDetail: PurchaseOrder.DTO? let purchaseOrders: [PurchaseOrder.DTO] let page: Int let limit: Int @@ -69,7 +76,12 @@ private struct PurchaseOrderCTX: Content { let hasPrevious: Bool let form: PurchaseOrderFormCTX? - init(page: Page, form: PurchaseOrderFormCTX?) { + init( + detail: PurchaseOrder.DTO? = nil, + page: Page, + form: PurchaseOrderFormCTX? + ) { + self.purchaseOrderDetail = detail self.purchaseOrders = page.items self.page = page.metadata.page self.limit = page.metadata.per diff --git a/Sources/App/Controllers/View/UsersViewController.swift b/Sources/App/Controllers/View/UsersViewController.swift index c172dbd..ecc44ef 100644 --- a/Sources/App/Controllers/View/UsersViewController.swift +++ b/Sources/App/Controllers/View/UsersViewController.swift @@ -1,31 +1,50 @@ +import Dependencies import Fluent import Vapor struct UserViewController: RouteCollection { + @Dependency(\.users) var users + private let api = UserApiController() func boot(routes: any RoutesBuilder) throws { let users = routes.protected.grouped("users") users.get(use: index(req:)) users.post(use: create(req:)) - users.group(":userID") { + users.group(":id") { + $0.get(use: details(req:)) $0.delete(use: delete(req:)) } } @Sendable func index(req: Request) async throws -> View { - try await req.view.render( - "users/index", - UsersCTX(users: api.getSortedUsers(req: req)) - ) + try await renderIndex(req) + } + + @Sendable + private func renderIndex(_ req: Request, _ user: User.DTO? = nil) async throws -> View { + let users = try await api.getSortedUsers(req: req) + return try await req.view.render("users/index", UsersCTX(user: user, users: users)) } @Sendable func create(req: Request) async throws -> View { - _ = try await api.create(req: req) - return try await req.view.render("users/table", ["users": api.getSortedUsers(req: req)]) + let user = try await api.create(req: req) + return try await req.view.render("users/table-row", user) + } + + @Sendable + func details(req: Request) async throws -> View { + let user = try await users.get(req.ensureIDPathComponent()) + // Check if the page has been rendered before. + guard req.isHtmxRequest else { + // Not an htmx-request, so render the whole page with the details. + return try await renderIndex(req, user) + } + // An htmx-request header was present, so just return the details, + return try await req.view.render("users/detail", ["user": user]) } @Sendable @@ -50,11 +69,11 @@ struct UserFormCTX: Content { formClass: "user-form", formId: "user-form", htmxTargetUrl: .post("/login\((next != nil && next != "/") ? "?next=\(next!)" : "")"), - htmxTarget: "body", + htmxTarget: "user-table", htmxPushUrl: true, htmxResetAfterRequest: true, htmxSwapOob: nil, - htmxSwap: nil, + htmxSwap: .afterbegin, context: .init(showConfirmPassword: false, showEmailInput: false, buttonLabel: "Sign In") ) ) @@ -78,10 +97,16 @@ struct UserFormCTX: Content { } private struct UsersCTX: Content { + let user: User.DTO? let users: [User.DTO] let form: UserFormCTX - init(users: [User.DTO], form: UserFormCTX? = nil) { + init( + user: User.DTO? = nil, + users: [User.DTO], + form: UserFormCTX? = nil + ) { + self.user = user self.users = users self.form = form ?? .create() } diff --git a/Sources/App/DB/EmployeeDB.swift b/Sources/App/DB/EmployeeDB.swift index 1f445b9..8e0a829 100644 --- a/Sources/App/DB/EmployeeDB.swift +++ b/Sources/App/DB/EmployeeDB.swift @@ -15,14 +15,19 @@ extension DependencyValues { @DependencyClient struct EmployeeDB: Sendable { var create: @Sendable (Employee.Create) async throws -> Employee.DTO - var fetchAll: @Sendable (Bool) async throws -> [Employee.DTO] + var fetchAll: @Sendable (FetchRequest) async throws -> [Employee.DTO] var get: @Sendable (Employee.IDValue) async throws -> Employee.DTO? var update: @Sendable (Employee.IDValue, Employee.Update) async throws -> Employee.DTO var delete: @Sendable (Employee.IDValue) async throws -> Void var toggleActive: @Sendable (Employee.IDValue) async throws -> Employee.DTO + enum FetchRequest { + case active + case `default` + } + func fetchAll() async throws -> [Employee.DTO] { - try await fetchAll(false) + try await fetchAll(.default) } func get(_ id: String?) async throws -> Employee.DTO? { @@ -43,12 +48,12 @@ extension EmployeeDB: TestDependencyKey { try await model.save(on: database) return model.toDTO() }, - fetchAll: { active in + fetchAll: { request in var query = Employee.query(on: database) .sort(\.$lastName) - if active { - query = query.filter(\.$active == active) + if request == .active { + query = query.filter(\.$active == true) } return try await query.all().map { $0.toDTO() } diff --git a/Sources/App/DB/UserDB.swift b/Sources/App/DB/UserDB.swift index 9233663..1512ecf 100644 --- a/Sources/App/DB/UserDB.swift +++ b/Sources/App/DB/UserDB.swift @@ -15,6 +15,7 @@ struct UserDB: Sendable { var create: @Sendable (User.Create) async throws -> User.DTO var delete: @Sendable (User.IDValue) async throws -> Void var fetchAll: @Sendable () async throws -> [User.DTO] + var get: @Sendable (User.IDValue) async throws -> User.DTO? var login: @Sendable (User) async throws -> UserToken } @@ -46,6 +47,9 @@ extension UserDB: TestDependencyKey { fetchAll: { try await User.query(on: db).all().map { $0.toDTO() } }, + get: { id in + try await User.find(id, on: db).map { $0.toDTO() } + }, login: { user in let token = try user.generateToken() try await token.save(on: db) diff --git a/Sources/App/DB/VendorBranchDB.swift b/Sources/App/DB/VendorBranchDB.swift index cd217f3..7ea01e6 100644 --- a/Sources/App/DB/VendorBranchDB.swift +++ b/Sources/App/DB/VendorBranchDB.swift @@ -14,13 +14,18 @@ public extension DependencyValues { public struct VendorBranchDB: Sendable { var create: @Sendable (VendorBranch.Create, Vendor.IDValue) async throws -> VendorBranch.DTO var delete: @Sendable (VendorBranch.IDValue) async throws -> Void - var fetchAll: @Sendable (Bool) async throws -> [VendorBranch.DTO] - var fetchForVendor: @Sendable (Vendor.IDValue) async throws -> [VendorBranch.DTO] + var fetchAll: @Sendable (FetchRequest) async throws -> [VendorBranch.DTO] var get: @Sendable (VendorBranch.IDValue) async throws -> VendorBranch.DTO? var update: @Sendable (VendorBranch.IDValue, VendorBranch.Update) async throws -> VendorBranch.DTO + enum FetchRequest: Equatable { + case `default` + case `for`(vendorID: Vendor.IDValue) + case withVendor + } + func fetchAll() async throws -> [VendorBranch.DTO] { - try await fetchAll(false) + try await fetchAll(.default) } } @@ -43,28 +48,30 @@ extension VendorBranchDB: TestDependencyKey { } try await branch.delete(on: db) }, - fetchAll: { withVendor in + fetchAll: { request in var query = VendorBranch.query(on: db) - if withVendor == true { + switch request { + case .withVendor: query = query.with(\.$vendor) + + case let .for(vendorID: vendorID): + let branches = try await Vendor.query(on: db) + .filter(\.$id == vendorID) + .with(\.$branches) + .first()? + .branches + .map { $0.toDTO() } + + guard let branches else { throw Abort(.badGateway, reason: "Vendor id not found.") } + return branches + + case .default: + break } return try await query.all().map { $0.toDTO() } - }, - fetchForVendor: { vendorID in - guard let vendor = try await Vendor.query(on: db) - .filter(\.$id == vendorID) - .with(\.$branches) - .first() - else { - throw Abort(.notFound) - } - - return vendor.branches.map { $0.toDTO() } - }, get: { id in - try await VendorBranch.find(id, on: db).map { $0.toDTO() } }, update: { id, updates in diff --git a/Sources/App/Extensions/Request+ensureValidContent.swift b/Sources/App/Extensions/Request+ensureValidContent.swift deleted file mode 100644 index fa36490..0000000 --- a/Sources/App/Extensions/Request+ensureValidContent.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Vapor - -extension Request { - func ensureValidContent(_ decoding: T.Type) throws -> T where T: Content, T: Validatable { - try T.validate(content: self) - return try content.decode(T.self) - } -} diff --git a/Sources/App/Extensions/Request+extensions.swift b/Sources/App/Extensions/Request+extensions.swift new file mode 100644 index 0000000..f08fda9 --- /dev/null +++ b/Sources/App/Extensions/Request+extensions.swift @@ -0,0 +1,22 @@ +import Vapor + +extension Request { + func ensureValidContent(_ decoding: T.Type) throws -> T where T: Content, T: Validatable { + try T.validate(content: self) + return try content.decode(T.self) + } + + func ensureIDPathComponent( + as decoding: T.Type = UUID.self, + key: String = "id" + ) throws -> T { + guard let id = parameters.get(key, as: T.self) else { + throw Abort(.badRequest, reason: "Id not supplied.") + } + return id + } + + var isHtmxRequest: Bool { + headers.contains(name: "hx-request") + } +} diff --git a/Sources/App/Extensions/RouteBuilder+protected.swift b/Sources/App/Extensions/RouteBuilder+protected.swift index 8fa9c93..f44065e 100644 --- a/Sources/App/Extensions/RouteBuilder+protected.swift +++ b/Sources/App/Extensions/RouteBuilder+protected.swift @@ -5,12 +5,16 @@ extension RoutesBuilder { // Used to ensure views are protected, redirects users to the login page if they're // not authenticated. var protected: any RoutesBuilder { - grouped( - User.credentialsAuthenticator(), - User.redirectMiddleware { req in - "login?next=\(req.url)" - } - ) + #if DEBUG + return self + #else + return grouped( + User.credentialsAuthenticator(), + User.redirectMiddleware { req in + "login?next=\(req.url)" + } + ) + #endif } func apiUnprotected(route: PathComponent) -> any RoutesBuilder { @@ -20,11 +24,15 @@ extension RoutesBuilder { // Allows basic or token authentication for api routes and prefixes the // given route with "/api/v1". func apiProtected(route: PathComponent) -> any RoutesBuilder { - let prefixed = grouped("api", "v1", route) - return prefixed.grouped( - User.authenticator(), - UserToken.authenticator(), - User.guardMiddleware() - ) + #if DEBUG + return apiUnprotected(route: route) + #else + let prefixed = grouped("api", "v1", route) + return prefixed.grouped( + User.authenticator(), + UserToken.authenticator(), + User.guardMiddleware() + ) + #endif } } diff --git a/Sources/App/Models/Employee.swift b/Sources/App/Models/Employee.swift index fee9e2e..d6348fc 100644 --- a/Sources/App/Models/Employee.swift +++ b/Sources/App/Models/Employee.swift @@ -27,22 +27,39 @@ final class Employee: Model, @unchecked Sendable { @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 + 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() -> DTO { - .init(id: id, firstName: $firstName.value, lastName: $lastName.value, active: $active.value) + .init( + id: id, + firstName: $firstName.value, + lastName: $lastName.value, + active: $active.value, + createdAt: createdAt, + updatedAt: updatedAt + ) } func applyUpdates(_ updates: Update) { @@ -68,7 +85,11 @@ extension Employee { let active: Bool? func toModel() -> Employee { - .init(firstName: firstName, lastName: lastName, active: active ?? true) + .init( + firstName: firstName, + lastName: lastName, + active: active ?? true + ) } } @@ -78,6 +99,8 @@ extension Employee { var firstName: String? var lastName: String? var active: Bool? + var createdAt: Date? + var updatedAt: Date? func toModel() -> Employee { let model = Employee() @@ -106,6 +129,8 @@ extension Employee { .field("first_name", .string, .required) .field("last_name", .string, .required) .field("is_active", .bool, .required, .sql(.default(true))) + .field("created_at", .datetime) + .field("updated_at", .datetime) .unique(on: "first_name", "last_name") .create() } diff --git a/Sources/App/Models/User.swift b/Sources/App/Models/User.swift index 9eb7b61..b2a9508 100644 --- a/Sources/App/Models/User.swift +++ b/Sources/App/Models/User.swift @@ -23,9 +23,20 @@ final class User: Model, @unchecked Sendable { @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) { + init( + id: UUID? = nil, + username: String, + email: String, + passwordHash: String + ) { self.id = id self.username = username self.email = email @@ -33,7 +44,13 @@ final class User: Model, @unchecked Sendable { } func toDTO() -> DTO { - .init(id: id, username: $username.value, email: $email.value) + .init( + id: id, + username: $username.value, + email: $email.value, + createdAt: createdAt, + updatedAt: updatedAt + ) } func generateToken() throws -> UserToken { @@ -58,6 +75,8 @@ extension User { let id: UUID? let username: String? let email: String? + let createdAt: Date? + let updatedAt: Date? } struct Migrate: AsyncMigration { @@ -69,6 +88,8 @@ extension User { .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() } diff --git a/Sources/App/Models/Vendor.swift b/Sources/App/Models/Vendor.swift index f609dff..555bc96 100644 --- a/Sources/App/Models/Vendor.swift +++ b/Sources/App/Models/Vendor.swift @@ -13,6 +13,12 @@ final class Vendor: Model, @unchecked Sendable { @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: [VendorBranch] @@ -29,7 +35,9 @@ final class Vendor: Model, @unchecked Sendable { name: $name.value, branches: ($branches.value != nil && $branches.value!.count > 0) ? $branches.value!.map { $0.toDTO() } - : (includeBranches == true) ? [] : nil + : (includeBranches == true) ? [] : nil, + createdAt: createdAt, + updatedAt: updatedAt ) } @@ -54,6 +62,8 @@ extension Vendor { var id: UUID? var name: String? var branches: [VendorBranch.DTO]? + let createdAt: Date? + let updatedAt: Date? func toModel() -> Vendor { let model = Vendor() @@ -72,6 +82,8 @@ extension Vendor { try await database.schema(Vendor.schema) .id() .field("name", .string, .required) + .field("created_at", .datetime) + .field("updated_at", .datetime) .unique(on: "name") .create() } diff --git a/Sources/App/Models/VendorBranch.swift b/Sources/App/Models/VendorBranch.swift index b93de1f..2eaedde 100644 --- a/Sources/App/Models/VendorBranch.swift +++ b/Sources/App/Models/VendorBranch.swift @@ -12,6 +12,12 @@ final class VendorBranch: Model, @unchecked Sendable { @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: Vendor @@ -24,7 +30,13 @@ final class VendorBranch: Model, @unchecked Sendable { } func toDTO() -> DTO { - .init(id: id, name: $name.value, vendorId: $vendor.id) + .init( + id: id, + name: $name.value, + vendorId: $vendor.id, + createdAt: createdAt, + updatedAt: updatedAt + ) } func applyUpdates(_ updates: Update) { @@ -50,6 +62,8 @@ extension VendorBranch { var id: UUID? var name: String? var vendorId: Vendor.IDValue? + let createdAt: Date? + let updatedAt: Date? func toModel() -> VendorBranch { let model = VendorBranch() @@ -74,6 +88,8 @@ extension VendorBranch { .id() .field("name", .string, .required) .field("vendor_id", .uuid, .required) + .field("created_at", .datetime) + .field("updated_at", .datetime) .foreignKey("vendor_id", references: Vendor.schema, "id", onDelete: .cascade) .create() }