import Hummingbird import HummingbirdElementary import Infrastructure import Persistence /// Serves what an account does for itself: its first sign-in, its profile and passkeys, and opening its shop. /// /// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to. public struct AccountController { // MARK: Properties /// The repository accounts and their passkeys are read and changed through. private let accounts: AccountRepository private let analytics: Analytics? private let assetVersion: String? /// The repository shops are opened through. private let catalogue: CatalogueRepository /// The store the signed-in account is read from. private let sessions: SessionStore // MARK: Initializers /// Creates an account controller. /// - Parameters: /// - accounts: the repository accounts and their passkeys are read and changed through. /// - catalogue: the repository shops are opened through. /// - sessions: the store the signed-in account is read from. /// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned. /// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it. public init( accounts: AccountRepository, catalogue: CatalogueRepository, sessions: SessionStore, assetVersion: String? = nil, analytics: Analytics? = nil ) { self.accounts = accounts self.analytics = analytics self.assetVersion = assetVersion self.catalogue = catalogue self.sessions = sessions } } // MARK: - RouterController extension AccountController: RouterController { // MARK: Properties public var routes: RouteCollection { let routes = RouteCollection(context: Context.self) routes.get(.init(.Route.welcome), use: welcome) routes.post(.init(.Route.welcome), use: introduce) routes.get(.init(.Route.account), use: account) routes.post(.init("\(String.Route.account)/name"), use: rename) routes.post(.init("\(String.Route.account)/passkeys/revoke"), use: revoke) routes.get(.init(.Route.openShop), use: shopForm) routes.post(.init(.Route.openShop), use: openShop) return routes } } // MARK: - Account private extension AccountController { /// Handles a request for the account screen. @Sendable func account( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .found) } return try await accountPage(user, request: request, context: context) } /// Renders the account screen — with a `422` when it reports a problem. func accountPage( _ user: User, problem: String? = nil, request: Request, context: Context ) async throws -> Response { let passkeys = try await accounts.passkeys(of: user) let viewer = await sessions.viewer(for: request) return HTMLResponse( status: problem == nil ? .ok : .unprocessableContent, additionalHeaders: .perViewer ) { [analytics, assetVersion] in AccountPage( user: user, passkeys: passkeys, problem: problem, viewer: viewer, assetVersion: assetVersion, analytics: analytics ) } .response(from: request, context: context) } /// Handles a new display name from the account screen. @Sendable func rename( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal) } let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context) guard let name = Self.displayName(form.name) else { return try await accountPage(user, problem: Self.nameProblem, request: request, context: context) } await sessions.update(try await accounts.rename(user, to: name)) return .redirect(to: .Route.account, type: .normal) } /// Handles the revoking of one of the account's own passkeys. @Sendable func revoke( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal) } let form = try await URLEncodedFormDecoder().decode(RevokeForm.self, from: request, context: context) try await accounts.revokePasskey(form.credential, of: user) return .redirect(to: "\(String.Route.account)#passkeys", type: .normal) } } // MARK: - First sign-in private extension AccountController { /// Handles the display name picked on first sign-in, and leads on to what the visitor came for. @Sendable func introduce( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn, type: .normal) } let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context) let returnPath = String.Route.safeReturn(form.return) guard let name = Self.displayName(form.name) else { return welcomePage(name: form.name, returnPath: returnPath, problem: Self.nameProblem, request: request, context: context) } await sessions.update(try await accounts.rename(user, to: name)) return .redirect(to: returnPath, type: .normal) } /// Handles a request for the first-sign-in screen. @Sendable func welcome( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn, type: .found) } return welcomePage( name: user.displayName, returnPath: .Route.safeReturn(request.uri.queryParameters.get("return")), request: request, context: context ) } /// Renders the first-sign-in screen — with a `422` when it reports a problem. func welcomePage( name: String, returnPath: String, problem: String? = nil, request: Request, context: Context ) -> Response { HTMLResponse( status: problem == nil ? .ok : .unprocessableContent, additionalHeaders: [.cacheControl: "no-store"] ) { [analytics, assetVersion] in WelcomePage( displayName: name, returnPath: returnPath, problem: problem, assetVersion: assetVersion, analytics: analytics ) } .response(from: request, context: context) } } // MARK: - Opening a shop private extension AccountController { /// Handles the open-a-shop form posted: opens the shop, and leads to its first listing. @Sendable func openShop( request: Request, context: Context ) async throws -> Response { guard let user = await sessions.user(for: request) else { return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .normal) } let values = try await URLEncodedFormDecoder().decode([String: String].self, from: request, context: context) let form = ShopForm(values: values) switch form.validated() { case .failure(let problems): return await shopPage(form, problems: problems.fields, request: request, context: context) case .success(let draft): do { _ = try await catalogue.openShop(draft, for: user) } catch ShopError.handleTaken { return await shopPage(form, problems: [.handle: "Another shop has that handle."], request: request, context: context) } catch ShopError.alreadyOwner { return .redirect(to: .Route.sellerDashboard, type: .normal) } return .redirect(to: NewListingPage.path, type: .normal) } } /// Handles a request for the open-a-shop form; an account that owns a shop is led to its dashboard. @Sendable func shopForm( request: Request, context: Context ) async throws -> Response { guard await sessions.user(for: request) != nil else { return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .found) } guard await sessions.viewer(for: request)?.hasShop != true else { return .redirect(to: .Route.sellerDashboard, type: .found) } return await shopPage(.init(), request: request, context: context) } /// Renders the open-a-shop form — with a `422` when it reports problems. func shopPage( _ form: ShopForm, problems: [ShopForm.Field: String] = [:], request: Request, context: Context ) async -> Response { let viewer = await sessions.viewer(for: request) return HTMLResponse( status: problems.isEmpty ? .ok : .unprocessableContent, additionalHeaders: .perViewer ) { [analytics, assetVersion] in OpenShopPage( form: form, problems: problems, viewer: viewer, assetVersion: assetVersion, analytics: analytics ) } .response(from: request, context: context) } } // MARK: - Helpers private extension AccountController { // MARK: Constants static var nameProblem: String { "Enter a display name of 2 to 40 characters." } // MARK: Methods /// A display name as posted, trimmed; or `nil` when it is too short or too long. static func displayName( _ name: String ) -> String? { let name = name.trimmingCharacters(in: .whitespacesAndNewlines) return (2...40).contains(name.count) ? name : nil } } // MARK: - Structs /// The fields a display name form posts. private struct NameForm: Decodable { let name: String let `return`: String? } /// The field a passkey's revoke form posts. private struct RevokeForm: Decodable { let credential: String }