Compare commits

...

10 Commits

24 changed files with 241 additions and 26 deletions

View File

@@ -2,6 +2,10 @@ name: CI
on:
push:
branches:
- main
- dev
pull_request:
workflow_dispatch:
jobs:

View File

@@ -47,6 +47,7 @@ jobs:
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
type=raw,value=prod
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If
# the build succeeds, it pushes the image to GitHub Packages. It uses the `context` parameter to define the build's context
# as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)"

67
README.md Normal file
View File

@@ -0,0 +1,67 @@
# vapor-po
The website for generating purchase orders.
## Usage
Generally the app should be ran through a docker container or docker-compose file. Examples are in
the `./docker` folder.
Images get built in the `CI` environment when a tag is pushed to the repository.
### Getting Started
When the application is first launched an admin user should be created in the running container.
Attach to the container using `docker exec` or `docker compose exec`, then run:
```
./App generate-admin --username "admin" --password "super-secret --confirmPassword "super-secret"
```
You can then login and generate user, employees, vendors, etc.
After the setup has been completed, then you should generate a mock purchase order and set the `id`
to the value you would like new purchase orders to start from. This should be done through calling
the api, as the web interface does not allow users to enter an id value.
#### Example
These examples use `httpie`, note the port is used for local development, in a production
environment you would just use the FQDN of where the application is running.
**Login**
```
http :8080/api/v1/login username="admin" password="super-secret" \
| jq '.["token"]' \
| pbcopy
```
**Set the token as environment variable**
```
export API_TOKEN=<clipboard contents>
```
**Get the employees to copy an id to use for the purchase order**
```
http -A bearer -a "$API_TOKEN" :8080/api/v1/employees
```
**Get the vendor branches to copy an id to use for the purchase order**
```
http -A bearer -a "$API_TOKEN" :8080/api/v1/vendors/branches
```
**Generate first po**
```
http -A bearer -a "$API_TOKEN" :8080/api/v1/purchase-orders \
id:="60000" \
materials="Test" \
customer="Testy McTestface" \
createdForID="<employee-id>"
vendorBranchID="<vendor-branch-id>"
```

View File

@@ -10,7 +10,10 @@ extension ViewController {
for: route,
isHtmxRequest: request.isHtmxRequest,
logger: request.logger,
authenticate: { request.session.authenticate($0) }
authenticate: { request.session.authenticate($0) },
currentUser: {
try request.auth.require(User.self)
}
)
return AnyHTMLResponse(value: html)
}

View File

@@ -37,6 +37,7 @@ public extension DatabaseClient.Employees {
guard let model = try await EmployeeModel.find(id, on: database) else {
throw NotFoundError()
}
database.logger.debug("Applying updates to employee: \(updates)")
try model.applyUpdate(updates)
try await model.save(on: database)
return try model.toDTO()
@@ -170,8 +171,10 @@ final class EmployeeModel: Model, @unchecked Sendable {
if let lastName = updates.lastName {
self.lastName = lastName
}
if let active = updates.active {
self.active = active
// NB: When html forms are submitted with a checkbox then active is nil
// in the update context.
if active, updates.active == nil || updates.active == false {
active = false
}
}
}

View File

@@ -91,6 +91,8 @@ extension PurchaseOrder.Create {
func toModel() throws -> PurchaseOrderModel {
try validate()
return .init(
id: id,
workOrder: workOrder,
materials: materials,
customer: customer,
truckStock: truckStock ?? false,

View File

@@ -1,6 +1,9 @@
import Dependencies
import Foundation
/// Represents an employee database model.
///
/// Employee's are who purchase orders can be generated for.
public struct Employee: Codable, Equatable, Identifiable, Sendable {
public var id: UUID
public var active: Bool
@@ -31,6 +34,8 @@ public struct Employee: Codable, Equatable, Identifiable, Sendable {
}
public extension Employee {
/// Represents the required fields for generating a new employee in the
/// database.
struct Create: Codable, Sendable, Equatable {
public let firstName: String
public let lastName: String
@@ -47,6 +52,8 @@ public extension Employee {
}
}
/// Represents the required fields for updating an existing employee in the
/// database.
struct Update: Codable, Sendable, Equatable {
public let firstName: String?
public let lastName: String?

View File

@@ -1,6 +1,11 @@
import Dependencies
import Foundation
/// Represents a purchase order database model.
///
/// A purchase order is generated on behalf of an `Employee` and issued to
/// a `VendorBranch`. It includes information about the customer / job it was created
/// for, the materials that were purchased, etc.
public struct PurchaseOrder: Codable, Equatable, Identifiable, Sendable {
public let id: Int
@@ -41,6 +46,7 @@ public struct PurchaseOrder: Codable, Equatable, Identifiable, Sendable {
public extension PurchaseOrder {
/// Represents the required fields for generating a new purchase order in the database.
struct Create: Codable, Sendable, Equatable {
public let id: Int?
@@ -73,6 +79,9 @@ public extension PurchaseOrder {
}
}
/// Represents the required fields for generating a new purchase order in the database,
/// without the user information who is issuing the request, which get's parsed from the
/// currently authenticated user's session and is used to generate the full `Create` request.
struct CreateIntermediate: Codable, Sendable, Equatable {
public let id: Int?
@@ -115,10 +124,12 @@ public extension PurchaseOrder {
}
}
/// Represents the context to search or filter purchase orders based on the
/// given parameters.
enum SearchContext: Sendable, Equatable {
case customer(String)
case vendor(VendorBranch.ID)
case employee(Employee.ID)
case vendor(VendorBranch.ID)
}
}

View File

@@ -4,6 +4,9 @@ import Foundation
public extension SiteRoute {
/// Represents api routes that can be interacted with.
///
/// These routes return json information, as opposed to html like the view routes.
enum Api: Sendable, Equatable {
case employee(EmployeeRoute)

View File

@@ -2,6 +2,7 @@ import CasePathsCore
import Foundation
@preconcurrency import URLRouting
/// Represents all the routes that our server can handle.
public enum SiteRoute: Sendable {
case api(SiteRoute.Api)
case health

View File

@@ -4,6 +4,10 @@ import Foundation
public extension SiteRoute {
// swiftlint:disable type_body_length
/// Represents view routes that can be interacted with.
///
/// These routes return html and are used to generate the web interface.
enum View: Sendable, Equatable {
case employee(SiteRoute.View.EmployeeRoute)
@@ -131,7 +135,7 @@ public extension SiteRoute {
}
public enum PurchaseOrderRoute: Sendable, Equatable {
case create(PurchaseOrder.Create)
case create(PurchaseOrder.CreateIntermediate)
case form
case get(id: PurchaseOrder.ID)
case index
@@ -151,11 +155,10 @@ public extension SiteRoute {
Field("materials", .string)
Field("customer", .string)
Optionally { Field("truckStock") { Bool.parser() } }
Field("createdByID") { User.ID.parser() }
Field("createdForID") { Employee.ID.parser() }
Field("vendorBranchID") { VendorBranch.ID.parser() }
}
.map(.memberwise(PurchaseOrder.Create.init))
.map(.memberwise(PurchaseOrder.CreateIntermediate.init))
}
}
Route(.case(Self.form)) {

View File

@@ -1,6 +1,11 @@
import Dependencies
import Foundation
/// Represents a user database model.
///
/// User's are who can login to the system and generate purchase orders, manage
/// employees, vendors, etc.
///
public struct User: Codable, Equatable, Identifiable, Sendable {
public var id: UUID
@@ -26,6 +31,7 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
public extension User {
/// Represents the fields needed to generate a new user in the database.
struct Create: Codable, Sendable, Equatable {
public let username: String
public let email: String
@@ -45,6 +51,7 @@ public extension User {
}
}
/// Represents the fields needed for new user to login.
struct Login: Codable, Sendable, Equatable {
public let username: String?
public let email: String?
@@ -61,6 +68,7 @@ public extension User {
}
}
/// Represents the fields needed to reset the password of a user.
struct ResetPassword: Codable, Equatable, Sendable {
public let password: String
public let confirmPassword: String
@@ -74,6 +82,8 @@ public extension User {
}
}
/// Represents a user token that can be used to authenticate a user, typically
/// used for interacting with api routes remotely.
struct Token: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let userID: User.ID
@@ -90,6 +100,7 @@ public extension User {
}
}
/// Represents the fields needed to update a user's attributes in the database.
struct Update: Codable, Equatable, Sendable {
public let username: String?
public let email: String?

View File

@@ -1,6 +1,10 @@
import Dependencies
import Foundation
/// Represents a vendor item in the database.
///
/// A vendor is parent item that contains one or more branches where purchase orders
/// can be issued to. It is primarily a name space to group related branches together.
public struct Vendor: Codable, Equatable, Identifiable, Sendable {
public var id: UUID
public var name: String
@@ -25,6 +29,7 @@ public struct Vendor: Codable, Equatable, Identifiable, Sendable {
public extension Vendor {
/// Represents the fields required to generate a new vendor in the database.
struct Create: Codable, Sendable, Equatable {
public let name: String
@@ -33,6 +38,7 @@ public extension Vendor {
}
}
/// Represents the fields required to update a vendor in the database.
struct Update: Codable, Sendable, Equatable {
public let name: String

View File

@@ -1,6 +1,10 @@
import Dependencies
import Foundation
/// Represents a vendor branch database model.
///
/// A vendor branch is who purchase orders can be issued to on behalf an `Employee`.
/// They are associated with a particular `Vendor`.
public struct VendorBranch: Codable, Equatable, Identifiable, Sendable {
public var id: UUID
public var name: String
@@ -24,6 +28,8 @@ public struct VendorBranch: Codable, Equatable, Identifiable, Sendable {
}
public extension VendorBranch {
/// Represents the fields required to generate a new vendor branch in the database.
struct Create: Codable, Sendable, Equatable {
public let name: String
public let vendorID: Vendor.ID
@@ -34,6 +40,10 @@ public extension VendorBranch {
}
}
/// Represents the details of a vendor branch, which includes the parent vendor item.
///
/// This is used in several of the views / api routes that require information about both the
/// vendor branch and it's associated parent vendor item.
struct Detail: Codable, Equatable, Identifiable, Sendable {
public var id: UUID
public var name: String
@@ -56,6 +66,7 @@ public extension VendorBranch {
}
}
/// Represents the fields that are used to update attributes of a vendor branch in the database.
struct Update: Codable, Sendable, Equatable {
public let name: String?

View File

@@ -16,6 +16,7 @@ public typealias AnySendableHTML = (any HTML & Sendable)
@DependencyClient
public struct ViewController: Sendable {
public typealias AuthenticateHandler = @Sendable (User) -> Void
public typealias CurrentUserHandler = @Sendable () throws -> User
public var view: @Sendable (Request) async throws -> AnySendableHTML
@@ -24,9 +25,16 @@ public struct ViewController: Sendable {
for route: SiteRoute.View,
isHtmxRequest: Bool,
logger: Logger,
authenticate: @escaping AuthenticateHandler
authenticate: @escaping AuthenticateHandler,
currentUser: @escaping CurrentUserHandler
) async throws -> AnySendableHTML {
try await view(.init(route, isHtmxRequest: isHtmxRequest, authenticate: authenticate, logger: logger))
try await view(.init(
route,
isHtmxRequest: isHtmxRequest,
authenticate: authenticate,
logger: logger,
currentUser: currentUser
))
}
public struct Request: Sendable {
@@ -34,17 +42,20 @@ public struct ViewController: Sendable {
public let isHtmxRequest: Bool
public let authenticate: AuthenticateHandler
public let logger: Logger
public let currentUser: CurrentUserHandler
public init(
_ route: SiteRoute.View,
isHtmxRequest: Bool,
authenticate: @escaping AuthenticateHandler,
logger: Logger
logger: Logger,
currentUser: @escaping CurrentUserHandler
) {
self.route = route
self.isHtmxRequest = isHtmxRequest
self.authenticate = authenticate
self.logger = logger
self.currentUser = currentUser
}
}

View File

@@ -9,7 +9,8 @@ extension ViewController: DependencyKey {
try await request.route.view(
isHtmxRequest: request.isHtmxRequest,
logger: request.logger,
authenticate: request.authenticate
authenticate: request.authenticate,
currentUser: request.currentUser
)
})
}

View File

@@ -12,14 +12,11 @@ public extension SiteRoute.View {
func view(
isHtmxRequest: Bool,
logger: Logger,
authenticate: @escaping @Sendable (User) -> Void
authenticate: @escaping @Sendable (User) -> Void,
currentUser: @escaping @Sendable () throws -> User
) async throws -> AnySendableHTML {
@Dependency(\.database.users) var users
switch self {
// case .index:
// // This get's redirected to purchase-orders route in the app / site handler.
// return nil
case let .employee(route):
return try await route.view(isHtmxRequest: isHtmxRequest)
@@ -38,7 +35,7 @@ public extension SiteRoute.View {
}
case let .purchaseOrder(route):
return try await route.view(isHtmxRequest: isHtmxRequest)
return try await route.view(isHtmxRequest: isHtmxRequest, currentUser: currentUser)
case let .resetPassword(route):
return try await route.view(isHtmxRequest: isHtmxRequest)
@@ -80,7 +77,7 @@ extension SiteRoute.View.EmployeeRoute {
return try await render(mainPage, isHtmxRequest, EmployeeForm(shouldShow: true))
case let .select(context: context):
return try await context.toHTML(employees: employees.fetchAll())
return try await context.toHTML(employees: employees.fetchAll(.active))
case .index:
return try await mainPage(EmployeeForm())
@@ -116,7 +113,10 @@ extension SiteRoute.View.PurchaseOrderRoute {
}
@Sendable
func view(isHtmxRequest: Bool) async throws -> AnySendableHTML {
func view(
isHtmxRequest: Bool,
currentUser: @escaping @Sendable () throws -> User
) async throws -> AnySendableHTML {
@Dependency(\.database.purchaseOrders) var purchaseOrders
switch self {
@@ -129,7 +129,9 @@ extension SiteRoute.View.PurchaseOrderRoute {
return try await route.view(isHtmxRequest: isHtmxRequest)
case let .create(purchaseOrder):
return try await PurchaseOrderTable.Row(purchaseOrder: purchaseOrders.create(purchaseOrder))
return try await PurchaseOrderTable.Row(
purchaseOrder: purchaseOrders.create(purchaseOrder.toCreate(createdByID: currentUser().id))
)
case .index:
return try await mainPage(PurchaseOrderForm())

View File

@@ -42,6 +42,20 @@ struct EmployeeForm: HTML {
.placeholder("Last Name"), .required
)
}
if let employee {
div(.class("row"), .style("margin: 20px; float: left;")) {
label(.for("active"), .style("margin-right: 15px;")) { h2 { "Active" } }
if employee.active {
input(
.type(.checkbox), .id("active"), .name("active"), .checked
)
} else {
input(
.type(.checkbox), .id("active"), .name("active")
)
}
}
}
div(.class("btn-row")) {
button(.type(.submit), .class("btn-primary")) {
buttonLabel
@@ -59,6 +73,18 @@ struct EmployeeForm: HTML {
}
}
}
if employee != nil {
h3 {
i {
span(.class("primary"), .style("padding-right: 15px;")) {
"Note:"
}
span(.class("secondary")) {
"It is better to mark an employee as in-active instead of deleting them."
}
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
import Elementary
import ElementaryHTMX
import SharedModels
struct PurchaseOrderFilter: HTML, Sendable {
var content: some HTML {
form(
.id("filter-form")
) {
input(.type(.text), .name("id"), .placeholder("Filter: (12345)"), .required)
}
}
}

View File

@@ -20,7 +20,7 @@ struct ViewControllerTests {
@Test
func employeeViews() async throws {
try await withSnapshotTesting(record: record) {
try await withSnapshotTesting(record: .missing) {
try await withDependencies {
$0.viewController = .liveValue
$0.database.employees = .mock
@@ -213,7 +213,8 @@ extension ViewController {
for: route,
isHtmxRequest: true,
logger: .init(label: "tests"),
authenticate: { _ in }
authenticate: { _ in },
currentUser: { .mock }
)
return html.renderFormatted()
}
@@ -428,6 +429,17 @@ extension PurchaseOrder.Create {
}
}
extension PurchaseOrder.CreateIntermediate {
static var mock: Self {
.init(
materials: "bar",
customer: "Testy McTestface",
createdForID: UUID(0),
vendorBranchID: UUID(0)
)
}
}
extension User.ResetPassword {
static var mock: Self {
.init(password: "super-secret", confirmPassword: "super-secret")

View File

@@ -8,9 +8,15 @@
<div class="col-2"></div>
<input type="text" class="col-5" name="lastName" value="McTestface" placeholder="Last Name" required>
</div>
<div class="row" style="margin: 20px; float: left;">
<label for="active" style="margin-right: 15px;">
<h2>Active</h2></label>
<input type="checkbox" id="active" name="active" checked>
</div>
<div class="btn-row">
<button type="submit" class="btn-primary">Update</button>
<button class="danger" hx-confirm="Are you sure you want to delete this employee?" hx-delete="/api/v1/employees/00000000-0000-0000-0000-000000000000" hx-target="#employee-00000000-0000-0000-0000-000000000000" hx-swap="outerHTML transition:true swap:1s">Delete</button>
</div>
</form>
<h3><i><span class="primary" style="padding-right: 15px;">Note:</span><span class="secondary">It is better to mark an employee as in-active instead of deleting them.</span></i></h3>
</div>

View File

@@ -23,7 +23,6 @@ struct PurchaseOrderViewRouteTests {
materials: "some",
customer: "Testy",
truckStock: false,
createdByID: id,
createdForID: id,
vendorBranchID: id
))))

View File

@@ -65,6 +65,7 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
# If your app or its dependencies import FoundationXML, also install `libxml2`.
# libxml2 \
sqlite3 \
curl \
&& rm -r /var/lib/apt/lists/*
# Create a vapor user and group with /app as its home directory

View File

@@ -21,7 +21,8 @@ services:
app:
image: hhe-po:latest
build:
context: .
context: ..
dockerfile: ./docker/Dockerfile
environment:
<<: *shared_environment
volumes:
@@ -30,12 +31,20 @@ services:
- '8080:8080'
labels:
- dev.orbstack.domains=po.local
healthcheck:
test: curl --fail -s http://0.0.0.0:8080/health || exit 1
interval: 1m30s
timeout: 10s
retries: 3
deploy:
replicas: 3
# 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: hhe-po:latest
build:
context: .
context: ..
dockerfile: ./docker/Dockerfile
environment:
<<: *shared_environment
command: ["migrate", "--yes"]
@@ -46,7 +55,8 @@ services:
revert:
image: hhe-po:latest
build:
context: .
context: ..
dockerfile: ./docker/Dockerfile
environment:
<<: *shared_environment
command: ["migrate", "--revert", "--yes"]